diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7e09c1a..f7b41ec 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.2.0 + VERSION: 1.3.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index befb829..24961a9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.2.0 + 1.3.0 net10.0 preview enable diff --git a/README.md b/README.md index 8bda147..8937e5f 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,16 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. +**Текущая версия:** `v1.3.0`. + --- ## ✨ Ключевые возможности ### 🤖 Telegram Бот - **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед). -- **✋ Интерактивная запись**: Игроки записываются на конкретные даты нажатием одной кнопки. -- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию и автоматически ведёт очередь ожидания. +- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. +- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. - **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания. - **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой. @@ -118,6 +120,8 @@ docker compose up -d Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard. +Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки. + ### Другие команды - `/listsessions` — Показать список всех актуальных игр в этой группе. - `/reschedulesession` — Перенести сессию на другое время с голосованием игроков. diff --git a/compose.yaml b/compose.yaml index 1e36802..f9bf5f6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.2.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.3.0 restart: always depends_on: db: @@ -29,7 +29,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.2.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.3.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs new file mode 100644 index 0000000..d96ade6 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs @@ -0,0 +1,217 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Rendering; +using Npgsql; +using Telegram.Bot; + +namespace GmRelay.Bot.Features.Sessions.CreateSession; + +public sealed record LeaveSessionCommand( + Guid SessionId, + long TelegramUserId, + string CallbackQueryId, + long ChatId, + int MessageId); + +internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers); +internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus); +internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string DisplayName); + +public sealed class LeaveSessionHandler( + NpgsqlDataSource dataSource, + ITelegramBotClient bot, + ILogger logger) +{ + public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + var transactionCommitted = false; + + try + { + var session = await connection.QuerySingleOrDefaultAsync( + """ + SELECT title AS Title, + batch_id AS BatchId, + status AS Status, + max_players AS MaxPlayers + FROM sessions + WHERE id = @SessionId + FOR UPDATE + """, + new { command.SessionId }, + transaction); + + if (session is null) + { + await transaction.RollbackAsync(ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); + return; + } + + if (SessionStatus.IsCancelled(session.Status)) + { + await transaction.RollbackAsync(ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия уже отменена.", cancellationToken: ct); + return; + } + + var participant = await connection.QuerySingleOrDefaultAsync( + """ + SELECT sp.id AS ParticipantRowId, + p.display_name AS DisplayName, + sp.registration_status AS RegistrationStatus + 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 + FOR UPDATE OF sp + """, + new { command.SessionId, command.TelegramUserId }, + transaction); + + if (participant is null) + { + await transaction.RollbackAsync(ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы не записаны на эту сессию.", cancellationToken: ct); + return; + } + + await connection.ExecuteAsync( + """ + DELETE FROM session_participants + WHERE id = @ParticipantRowId + """, + new { participant.ParticipantRowId }, + transaction); + + var activeParticipantsAfterLeave = await connection.ExecuteScalarAsync( + """ + SELECT COUNT(*) + FROM session_participants + WHERE session_id = @SessionId + AND is_gm = false + AND registration_status = @Active + """, + new { command.SessionId, Active = ParticipantRegistrationStatus.Active }, + transaction); + + var waitlistedParticipants = await connection.ExecuteScalarAsync( + """ + SELECT COUNT(*) + FROM session_participants + WHERE session_id = @SessionId + AND is_gm = false + AND registration_status = @Waitlisted + """, + new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, + transaction); + + string? promotedDisplayName = null; + if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves( + participant.RegistrationStatus, + session.MaxPlayers, + activeParticipantsAfterLeave, + waitlistedParticipants)) + { + var promoted = await connection.QuerySingleAsync( + """ + SELECT sp.id AS ParticipantRowId, + p.display_name AS DisplayName + 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 = @Waitlisted + ORDER BY sp.created_at ASC, sp.id ASC + LIMIT 1 + FOR UPDATE OF sp + """, + new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, + transaction); + + await connection.ExecuteAsync( + """ + UPDATE session_participants + SET registration_status = @Active, + rsvp_status = @Pending, + responded_at = NULL + WHERE id = @ParticipantRowId + """, + new + { + promoted.ParticipantRowId, + Active = ParticipantRegistrationStatus.Active, + Pending = RsvpStatus.Pending + }, + transaction); + + promotedDisplayName = promoted.DisplayName; + } + + 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 { session.BatchId }, + transaction)).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 { session.BatchId }, + transaction)).ToList(); + + await transaction.CommitAsync(ct); + transactionCommitted = true; + + var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants); + + await bot.EditMessageText( + chatId: command.ChatId, + messageId: command.MessageId, + text: renderResult.Text, + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, + replyMarkup: renderResult.Markup, + cancellationToken: ct); + + var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted + ? "Вы удалены из листа ожидания." + : promotedDisplayName is null + ? "Вы отписались от сессии." + : $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}."; + + await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при самостоятельной отмене записи на сессию {SessionId}", command.SessionId); + if (!transactionCommitted) + { + await transaction.RollbackAsync(ct); + } + + var errorText = transactionCommitted + ? "Запись снята, но не удалось обновить сообщение расписания." + : "Произошла ошибка при отмене записи."; + await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct); + } + } +} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index e4817d9..4f04574 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -20,6 +20,7 @@ public sealed class UpdateRouter( HandleRsvpHandler rsvpHandler, CreateSessionHandler createSessionHandler, JoinSessionHandler joinSessionHandler, + LeaveSessionHandler leaveSessionHandler, PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler, CancelSessionHandler cancelSessionHandler, DeleteSessionHandler deleteSessionHandler, @@ -73,6 +74,19 @@ public sealed class UpdateRouter( return; } + if (action == "leave_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var leaveSessionId)) + { + var command = new LeaveSessionCommand( + SessionId: leaveSessionId, + TelegramUserId: query.From.Id, + CallbackQueryId: query.Id, + ChatId: message.Chat.Id, + MessageId: message.MessageId); + + await leaveSessionHandler.HandleAsync(command, ct); + return; + } + if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId)) { var command = new CancelSessionCommand( @@ -210,6 +224,7 @@ public sealed class UpdateRouter( Ссылка: https://link /listsessions — список предстоящих сессий + Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти». /help — эта справка """, cancellationToken: ct); diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index 188b51d..e6125c7 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -54,6 +54,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.Shared/Domain/SessionCapacityRules.cs b/src/GmRelay.Shared/Domain/SessionCapacityRules.cs index 6347256..d7b703b 100644 --- a/src/GmRelay.Shared/Domain/SessionCapacityRules.cs +++ b/src/GmRelay.Shared/Domain/SessionCapacityRules.cs @@ -23,4 +23,14 @@ public static class SessionCapacityRules return !maxPlayers.HasValue || activeParticipants < maxPlayers.Value; } + + public static bool ShouldPromoteAfterParticipantLeaves( + string removedRegistrationStatus, + int? maxPlayers, + int activeParticipantsAfterLeave, + int waitlistedParticipants) + { + return removedRegistrationStatus == ParticipantRegistrationStatus.Active + && CanPromoteWaitlistedPlayer(maxPlayers, activeParticipantsAfterLeave, waitlistedParticipants); + } } diff --git a/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs b/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs index 8f58b04..70db7c2 100644 --- a/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs +++ b/src/GmRelay.Shared/Rendering/SessionBatchRenderer.cs @@ -60,6 +60,11 @@ public static class SessionBatchRenderer buttons.Add(new[] { InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"), + InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}") + }); + + buttons.Add(new[] + { InlineKeyboardButton.WithCallbackData($"❌ Отменить {dateTitle} (ГМ)", $"cancel_session:{session.SessionId}"), InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}") } diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index fd50abc..63feebb 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -47,7 +47,7 @@ - + diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 0631ef1..1a1244d 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.2.0 + GM-Relay Design System v1.3.0 Dark RPG Dashboard Theme ============================================ */ diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs index d3d2638..f3fd8f9 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs @@ -28,4 +28,26 @@ public sealed class SessionCapacityRulesTests Assert.False(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 3, waitlistedParticipants: 1)); Assert.False(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 2, waitlistedParticipants: 0)); } + + [Fact] + public void ShouldPromoteAfterParticipantLeaves_ShouldOnlyPromoteAfterActiveParticipantLeaves() + { + Assert.True(SessionCapacityRules.ShouldPromoteAfterParticipantLeaves( + removedRegistrationStatus: ParticipantRegistrationStatus.Active, + maxPlayers: 2, + activeParticipantsAfterLeave: 1, + waitlistedParticipants: 1)); + + Assert.False(SessionCapacityRules.ShouldPromoteAfterParticipantLeaves( + removedRegistrationStatus: ParticipantRegistrationStatus.Waitlisted, + maxPlayers: 2, + activeParticipantsAfterLeave: 1, + waitlistedParticipants: 1)); + + Assert.False(SessionCapacityRules.ShouldPromoteAfterParticipantLeaves( + removedRegistrationStatus: ParticipantRegistrationStatus.Active, + maxPlayers: 2, + activeParticipantsAfterLeave: 1, + waitlistedParticipants: 0)); + } } diff --git a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs index 6011e48..04bd7c1 100644 --- a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs +++ b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs @@ -42,13 +42,15 @@ public sealed class SessionBatchRendererTests Assert.Contains("Лист ожидания (1)", text); Assert.Contains("Charlie", text); Assert.Contains("Bob", text); - Assert.Equal(2, result.Markup.InlineKeyboard.Count()); + Assert.Equal(4, result.Markup.InlineKeyboard.Count()); Assert.Collection( buttons.Select(button => button.CallbackData), callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData), + callbackData => Assert.Equal($"leave_session:{firstSessionId}", callbackData), callbackData => Assert.Equal($"cancel_session:{firstSessionId}", callbackData), callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData), callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData), + callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData), callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData), callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData), callbackData => Assert.Equal($"promote_waitlist:{secondSessionId}", callbackData));