From a1ec688ec89ef9e5f4d0fab1a1ace9c036cd723a Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 27 Apr 2026 14:58:32 +0300 Subject: [PATCH] feat: add multi-option reschedule voting --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 16 +- compose.yaml | 4 +- .../HandleRescheduleTimeInputHandler.cs | 170 +++++++-- .../HandleRescheduleVoteHandler.cs | 354 +++++------------ .../InitiateRescheduleHandler.cs | 15 +- .../RescheduleSession/RescheduleVoteRules.cs | 42 +- .../RescheduleVotingDeadlineService.cs | 361 ++++++++++++++++++ .../RescheduleVotingInput.cs | 110 ++++++ .../Infrastructure/Telegram/UpdateRouter.cs | 10 +- ...009__add_multi_option_reschedule_votes.sql | 35 ++ src/GmRelay.Bot/Program.cs | 1 + src/GmRelay.Web/wwwroot/app.css | 2 +- .../HandleRescheduleTimeInputHandlerTests.cs | 109 +++++- .../RescheduleVoteRulesTests.cs | 54 ++- 16 files changed, 929 insertions(+), 358 deletions(-) create mode 100644 src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs create mode 100644 src/GmRelay.Bot/Migrations/V009__add_multi_option_reschedule_votes.sql diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index dc7f3fc..397973f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.6.0 + VERSION: 1.7.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index be2050f..42b0b95 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.6.0 + 1.7.0 net10.0 preview enable diff --git a/README.md b/README.md index dd931a1..d875fcd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.6.0`. +**Текущая версия:** `v1.7.0`. --- @@ -16,6 +16,7 @@ - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. - **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram. +- **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант. - **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются. - **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой. - **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**. @@ -129,6 +130,17 @@ docker compose up -d ### Делегирование управления На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM. +### Перенос сессии голосованием +Owner или co-GM нажимает кнопку `⏰ Перенести` у нужной сессии и отправляет в чат 2-3 варианта нового времени вместе с дедлайном: + +```text +25.04.2026 19:30 +26.04.2026 18:00 +Дедлайн: 25.04.2026 12:00 +``` + +Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним. + ### Bulk-операции в Web Dashboard На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут: - обновить общий `title` и `link` сразу у всех сессий batch; @@ -142,7 +154,7 @@ docker compose up -d ### Другие команды - `/listsessions` — Показать список всех актуальных игр в этой группе. -- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков. +- `⏰ Перенести` в сообщении расписания — Запустить голосование по 2-3 вариантам нового времени. - `/deletesession` — Удалить сессию. - `/exportcalendar` — Получить `.ics` файл с играми. - `/help` — Справка по формату. diff --git a/compose.yaml b/compose.yaml index acef2d3..06d6821 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.6.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.7.0 restart: always depends_on: db: @@ -29,7 +29,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.6.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.7.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index b5517f0..590d786 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -25,7 +25,8 @@ internal sealed record VoteParticipantDto( /// /// 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. /// 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: "⚠️ Не удалось распознать время. Используйте формат: ДД.ММ.ГГГГ ЧЧ:ММ\nНапример: 25.04.2026 19:30", + text: $"⚠️ {parseError}\n\nИспользуйте формат:\n25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00", 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( """ @@ -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}. {option.ProposedAt.FormatMoscow()} (МСК)")); var directText = $""" 🔄 Голосование за перенос сессии 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} 📅 Текущее время: {proposal.CurrentScheduledAt.FormatMoscow()} (МСК) - 📅 Новое время: {newTime.FormatMoscow()} (МСК) + 🗳 Варианты: + {optionsText} + + ⏳ Дедлайн: {votingInput.Deadline.FormatMoscow()} (МСК) Проголосуйте кнопкой в групповом сообщении. """; @@ -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 options, IReadOnlyList participants, - IReadOnlyCollection approvedPlayerIds) + IReadOnlyList 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 { $"🔄 Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»", "", $"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)", - $"📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)", + $"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)", "", - "Для переноса нужно согласие всех участников:" + "Выберите один из вариантов:" }; - 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}. {option.ProposedAt.FormatMoscow()} (МСК) — {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 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 diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs index 968d37c..5e0b5fe 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -1,16 +1,12 @@ using Dapper; -using GmRelay.Bot.Features.Notifications; 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, + Guid OptionId, long TelegramUserId, string CallbackQueryId, long ChatId, @@ -19,20 +15,13 @@ public sealed record HandleRescheduleVoteCommand( internal sealed record VoteProposalDto( Guid Id, Guid SessionId, - DateTime ProposedAt, + DateTimeOffset VotingDeadlineAt, string Title, - DateTime CurrentScheduledAt, - Guid BatchId, - string SessionStatus, - long TelegramChatId, - int? ConfirmationMessageId, - int? BatchMessageId, - string NotificationMode); + DateTime CurrentScheduledAt); public sealed class HandleRescheduleVoteHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, - DirectSessionNotificationSender directSender, ILogger logger) { public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct) @@ -44,21 +33,15 @@ public sealed class HandleRescheduleVoteHandler( """ SELECT rp.id AS Id, rp.session_id AS SessionId, - rp.proposed_at AS ProposedAt, + rp.voting_deadline_at AS VotingDeadlineAt, 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, - s.notification_mode AS NotificationMode - FROM reschedule_proposals rp + 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 - JOIN game_groups g ON g.id = s.group_id - WHERE rp.id = @ProposalId AND rp.status = 'Voting' + WHERE ro.id = @OptionId AND rp.status = 'Voting' """, - new { command.ProposalId }, + new { command.OptionId }, transaction); if (proposal is null) @@ -70,6 +53,16 @@ public sealed class HandleRescheduleVoteHandler( return; } + if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow) + { + await bot.AnswerCallbackQuery( + command.CallbackQueryId, + "Дедлайн уже прошёл. Результаты скоро будут применены.", + showAlert: true, + cancellationToken: ct); + return; + } + var playerId = await connection.ExecuteScalarAsync( """ SELECT p.id @@ -94,268 +87,91 @@ public sealed class HandleRescheduleVoteHandler( await connection.ExecuteAsync( """ - INSERT INTO reschedule_votes (proposal_id, player_id, vote) - VALUES (@ProposalId, @PlayerId, @Vote) + INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id) + VALUES (@ProposalId, @PlayerId, @OptionId) ON CONFLICT (proposal_id, player_id) DO UPDATE - SET vote = EXCLUDED.vote, + SET option_id = EXCLUDED.option_id, voted_at = now() """, - new { command.ProposalId, PlayerId = playerId.Value, command.Vote }, + new + { + ProposalId = proposal.Id, + PlayerId = playerId.Value, + command.OptionId + }, transaction); - var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase) - ? new List() - : (await connection.QueryAsync( - """ - SELECT p.id AS PlayerId, - p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername, - p.telegram_id 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 - """, - new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, - transaction)).ToList(); - - var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase) - ? new HashSet() - : (await connection.QueryAsync( - """ - 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) - { - var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction); - - 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( - "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId", - new { command.TelegramUserId }); - - try - { - await bot.EditMessageText( - chatId: command.ChatId, - messageId: command.MessageId, - text: $"❌ Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 {proposal.CurrentScheduledAt.FormatMoscow()} (МСК)", - 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); - - var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); - if (mode.ShouldSendDirectMessages()) - { - await directSender.SendAsync( - directRecipients, - $"❌ Перенос сессии отклонён\n\n📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)}\n📅 Время остаётся прежним: {proposal.CurrentScheduledAt.FormatMoscow()} (МСК)", - "reschedule-rejected", - proposal.SessionId, - ct); - } - - logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId); - return; - } - - if (decision.ShouldRescheduleSession) - { - var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction); - 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, - one_hour_reminder_processed_at = 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 - AND registration_status = @Active - """, - new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, - transaction); - } - - await transaction.CommitAsync(ct); - - try - { - await bot.EditMessageText( - chatId: command.ChatId, - messageId: command.MessageId, - text: $"✅ Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!\n\nВсе участники согласились.\n📅 Новое время: {proposal.ProposedAt.FormatMoscow()} (МСК)\n\nУведомления будут приходить согласно новому расписанию.", - 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); - - var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); - if (mode.ShouldSendDirectMessages()) - { - await directSender.SendAsync( - directRecipients, - $"✅ Сессия перенесена\n\n📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)}\n📅 Новое время: {proposal.ProposedAt.FormatMoscow()} (МСК)", - "reschedule-approved", - proposal.SessionId, - 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 static async Task> LoadDirectRecipients( - Npgsql.NpgsqlConnection connection, - Guid sessionId, - Npgsql.NpgsqlTransaction transaction) - { - return (await connection.QueryAsync( + var participants = (await connection.QueryAsync( """ - SELECT p.telegram_id AS TelegramId, - p.display_name AS DisplayName + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + p.telegram_id 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 { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }, + new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, transaction)).ToList(); - } - private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct) - { + var options = (await connection.QueryAsync( + """ + 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( + """ + SELECT rov.option_id AS OptionId, + p.id AS PlayerId, + p.display_name AS DisplayName, + p.telegram_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); + + var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage( + proposal.Title, + proposal.CurrentScheduledAt, + proposal.VotingDeadlineAt, + options, + participants, + votes); + var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options); + try { - await using var connection = await dataSource.OpenConnectionAsync(ct); - - var batchSessions = (await connection.QueryAsync( - "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", - new { proposal.BatchId })).ToList(); - - var batchParticipants = (await connection.QueryAsync( - """ - 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 { 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}» перенесена на {proposal.ProposedAt.FormatMoscow()} (МСК).", - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - cancellationToken: ct); - } + 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 batch message for proposal {ProposalId}", proposal.Id); + logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id); } + + await bot.AnswerCallbackQuery( + command.CallbackQueryId, + "Ваш голос учтён. До дедлайна его можно изменить.", + cancellationToken: ct); } } diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs index e195bc1..c6238ff 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs @@ -23,7 +23,7 @@ internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage); /// /// Handles the "⏰ Перенести" button press from the batch message. /// Creates a reschedule proposal in AwaitingTime status and prompts -/// the GM to enter the new time via a regular text message. +/// the GM to enter 2-3 new time options and a voting deadline. /// public sealed class InitiateRescheduleHandler( NpgsqlDataSource dataSource, @@ -92,11 +92,20 @@ public sealed class InitiateRescheduleHandler( // 4. Prompt GM in chat await bot.AnswerCallbackQuery(command.CallbackQueryId, - "Введите новое время в чат (формат: ДД.ММ.ГГГГ ЧЧ:ММ)", cancellationToken: ct); + "Введите 2-3 варианта времени и дедлайн голосования.", cancellationToken: ct); await bot.SendMessage( chatId: command.ChatId, - text: $"⏰ Укажите новое время для сессии «{session.Title}» в формате:\nДД.ММ.ГГГГ ЧЧ:ММ\n\nНапример: 25.04.2026 19:30", + text: $""" + ⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования. + + Формат: + 25.04.2026 19:30 + 26.04.2026 18:00 + Дедлайн: 25.04.2026 12:00 + + Дедлайн должен быть в будущем и раньше первого предложенного времени. + """, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct); } diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs index 00365c9..01d8d86 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs @@ -9,27 +9,57 @@ internal enum RescheduleVoteOutcome internal sealed record RescheduleVoteDecision( RescheduleVoteOutcome Outcome, - string CallbackText, - bool ShouldRescheduleSession, - bool ShouldResetParticipantRsvps); + string Reason, + Guid? SelectedOptionId = null, + string CallbackText = "", + bool ShouldRescheduleSession = false, + bool ShouldResetParticipantRsvps = false); internal static class RescheduleVoteRules { + public static RescheduleVoteDecision SelectWinner(IReadOnlyList voteCounts) + { + var maxVotes = voteCounts.Count == 0 ? 0 : voteCounts.Max(x => x.VoteCount); + if (maxVotes == 0) + { + return new RescheduleVoteDecision( + RescheduleVoteOutcome.Rejected, + "Никто не проголосовал до дедлайна, перенос не применяется."); + } + + var winners = voteCounts.Where(x => x.VoteCount == maxVotes).ToList(); + if (winners.Count > 1) + { + return new RescheduleVoteDecision( + RescheduleVoteOutcome.Rejected, + "Голоса разделились поровну, перенос не применяется."); + } + + return new RescheduleVoteDecision( + RescheduleVoteOutcome.Approved, + "Победил вариант с большинством голосов.", + winners[0].OptionId, + ShouldRescheduleSession: true, + ShouldResetParticipantRsvps: true); + } + public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants) { if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase)) { return new RescheduleVoteDecision( Outcome: RescheduleVoteOutcome.Rejected, - CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.", - ShouldRescheduleSession: false, - ShouldResetParticipantRsvps: false); + Reason: "\u041e\u0434\u0438\u043d \u0438\u0437 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432 \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b \u043f\u0435\u0440\u0435\u043d\u043e\u0441.", + CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430."); } var everyoneApproved = approvedParticipants == totalParticipants; return new RescheduleVoteDecision( Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending, + Reason: everyoneApproved + ? "\u0412\u0441\u0435 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b." + : "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0435\u0442\u0441\u044f.", CallbackText: everyoneApproved ? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e." : "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!", diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs new file mode 100644 index 0000000..ce63e95 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -0,0 +1,361 @@ +using Dapper; +using GmRelay.Bot.Features.Notifications; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Rendering; +using Npgsql; +using Telegram.Bot; +using Telegram.Bot.Types.Enums; + +namespace GmRelay.Bot.Features.Sessions.RescheduleSession; + +internal sealed record DueRescheduleProposalDto( + Guid Id, + Guid SessionId, + DateTimeOffset VotingDeadlineAt, + string Title, + DateTime CurrentScheduledAt, + Guid BatchId, + int? BatchMessageId, + int? VoteMessageId, + long TelegramChatId, + string NotificationMode); + +public sealed class RescheduleVotingDeadlineService( + NpgsqlDataSource dataSource, + ITelegramBotClient bot, + DirectSessionNotificationSender directSender, + ILogger 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 + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + var proposalIds = (await connection.QueryAsync( + """ + SELECT id + FROM reschedule_proposals + WHERE status = 'Voting' + AND voting_deadline_at IS NOT NULL + AND voting_deadline_at <= now() + ORDER BY voting_deadline_at + LIMIT 25 + """)).ToList(); + + 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) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + + var proposal = await connection.QuerySingleOrDefaultAsync( + """ + SELECT rp.id AS Id, + rp.session_id AS SessionId, + rp.voting_deadline_at AS VotingDeadlineAt, + rp.vote_message_id AS VoteMessageId, + s.title AS Title, + s.scheduled_at AS CurrentScheduledAt, + s.batch_id AS BatchId, + s.batch_message_id AS BatchMessageId, + s.notification_mode AS NotificationMode, + 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' + AND rp.voting_deadline_at IS NOT NULL + AND rp.voting_deadline_at <= now() + FOR UPDATE + """, + new { ProposalId = proposalId }, + transaction); + + if (proposal is null) + return; + + var participants = (await connection.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + p.telegram_id 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( + """ + 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( + """ + SELECT rov.option_id AS OptionId, + p.id AS PlayerId, + p.display_name AS DisplayName, + p.telegram_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(); + + var voteCounts = options + .Select(option => new RescheduleOptionVoteCount( + option.OptionId, + votes.Count(vote => vote.OptionId == option.OptionId))) + .ToList(); + var decision = RescheduleVoteRules.SelectWinner(voteCounts); + var selectedOption = decision.SelectedOptionId is { } selectedOptionId + ? options.Single(x => x.OptionId == selectedOptionId) + : null; + + if (selectedOption is not null) + { + await connection.ExecuteAsync( + """ + UPDATE sessions + SET scheduled_at = @NewTime, + status = @Status, + confirmation_message_id = NULL, + link_message_id = NULL, + one_hour_reminder_processed_at = NULL, + updated_at = now() + WHERE id = @SessionId + """, + new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned }, + transaction); + + await connection.ExecuteAsync( + """ + UPDATE session_participants + SET rsvp_status = 'Pending', + responded_at = NULL + WHERE session_id = @SessionId + AND is_gm = false + AND registration_status = @Active + """, + new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, + transaction); + + await connection.ExecuteAsync( + """ + UPDATE reschedule_proposals + SET status = 'Approved', + selected_option_id = @SelectedOptionId, + proposed_at = @ProposedAt + WHERE id = @ProposalId + """, + new + { + ProposalId = proposal.Id, + SelectedOptionId = selectedOption.OptionId, + ProposedAt = selectedOption.ProposedAt + }, + transaction); + } + else + { + await connection.ExecuteAsync( + "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId", + new { ProposalId = proposal.Id }, + transaction); + } + + var directRecipients = participants + .Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)) + .ToList(); + + await transaction.CommitAsync(ct); + + await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct); + + if (selectedOption is not null) + { + await TryUpdateBatchMessage(proposal, ct); + } + + var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); + if (mode.ShouldSendDirectMessages()) + { + await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct); + } + + logger.LogInformation( + "Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", + proposal.Id, + proposal.SessionId, + decision.Outcome); + } + + private async Task TryUpdateVoteMessage( + DueRescheduleProposalDto proposal, + IReadOnlyList options, + IReadOnlyList participants, + IReadOnlyList votes, + RescheduleVoteDecision decision, + RescheduleOptionDto? selectedOption, + CancellationToken ct) + { + if (proposal.VoteMessageId is null) + return; + + try + { + var resultText = selectedOption is not null + ? $"✅ Голосование завершено.\nПобедил вариант {selectedOption.DisplayOrder}: {selectedOption.ProposedAt.FormatMoscow()} (МСК)." + : $"❌ Голосование завершено.\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}"; + + var text = $""" + {HandleRescheduleTimeInputHandler.BuildVotingMessage( + proposal.Title, + proposal.CurrentScheduledAt, + proposal.VotingDeadlineAt, + options, + participants, + votes)} + + {resultText} + """; + + await bot.EditMessageText( + chatId: proposal.TelegramChatId, + messageId: proposal.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}", proposal.Id); + } + } + + private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct) + { + try + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var batchSessions = (await connection.QueryAsync( + "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", + new { proposal.BatchId })).ToList(); + + var batchParticipants = (await connection.QueryAsync( + """ + 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 { 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: ParseMode.Html, + replyMarkup: renderResult.Markup, + cancellationToken: ct); + } + else + { + await bot.SendMessage( + chatId: proposal.TelegramChatId, + text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».", + parseMode: ParseMode.Html, + cancellationToken: ct); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id); + } + } + + private async Task SendDirectResult( + DueRescheduleProposalDto proposal, + IReadOnlyList recipients, + RescheduleVoteDecision decision, + RescheduleOptionDto? selectedOption, + CancellationToken ct) + { + var htmlText = selectedOption is not null + ? $""" + ✅ Сессия перенесена по итогам голосования + + 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} + 📅 Новое время: {selectedOption.ProposedAt.FormatMoscow()} (МСК) + """ + : $""" + ❌ Перенос сессии отклонён по итогам голосования + + 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} + 📅 Время остаётся прежним: {proposal.CurrentScheduledAt.FormatMoscow()} (МСК) + Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)} + """; + + await directSender.SendAsync( + recipients, + htmlText, + selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected", + proposal.SessionId, + ct); + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs new file mode 100644 index 0000000..32de365 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs @@ -0,0 +1,110 @@ +using System.Text.RegularExpressions; +using GmRelay.Shared.Domain; + +namespace GmRelay.Bot.Features.Sessions.RescheduleSession; + +internal sealed record RescheduleVotingInput( + IReadOnlyList Options, + DateTimeOffset Deadline) +{ + private static readonly Regex DateTimePattern = new( + @"(?\d{1,2}\.\d{2}\.\d{4})\s+(?