feat: allow players to leave sessions
Deploy Telegram Bot / build-and-push (push) Failing after 39s
Deploy Telegram Bot / deploy (push) Has been skipped

This commit is contained in:
2026-04-24 17:57:13 +03:00
parent 9c91057798
commit f45985041b
13 changed files with 285 additions and 9 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.2.0 VERSION: 1.3.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.2.0</Version> <Version>1.3.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+6 -2
View File
@@ -4,14 +4,16 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.3.0`.
--- ---
## ✨ Ключевые возможности ## ✨ Ключевые возможности
### 🤖 Telegram Бот ### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед). - **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
- **✋ Интерактивная запись**: Игроки записываются на конкретные даты нажатием одной кнопки. - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию и автоматически ведёт очередь ожидания. - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания. - **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой. - **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
@@ -118,6 +120,8 @@ docker compose up -d
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard. Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Другие команды ### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе. - `/listsessions` — Показать список всех актуальных игр в этой группе.
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков. - `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.2.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.3.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -29,7 +29,7 @@ services:
- gmrelay - gmrelay
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.2.0 image: git.codeanddice.ru/toutsu/gmrelay-web:1.3.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -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<LeaveSessionHandler> 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<LeaveSessionInfoDto>(
"""
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<LeaveSessionParticipantDto>(
"""
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<int>(
"""
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<int>(
"""
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<LeaveSessionPromotionDto>(
"""
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<SessionBatchDto>(
"""
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<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 { 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);
}
}
}
@@ -20,6 +20,7 @@ public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler, HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler, CreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler, JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler, PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
CancelSessionHandler cancelSessionHandler, CancelSessionHandler cancelSessionHandler,
DeleteSessionHandler deleteSessionHandler, DeleteSessionHandler deleteSessionHandler,
@@ -73,6 +74,19 @@ public sealed class UpdateRouter(
return; 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)) if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId))
{ {
var command = new CancelSessionCommand( var command = new CancelSessionCommand(
@@ -210,6 +224,7 @@ public sealed class UpdateRouter(
Ссылка: https://link Ссылка: https://link
/listsessions список предстоящих сессий /listsessions список предстоящих сессий
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
/help эта справка /help эта справка
""", """,
cancellationToken: ct); cancellationToken: ct);
+1
View File
@@ -54,6 +54,7 @@ builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<SendJoinLinkHandler>(); builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<CreateSessionHandler>(); builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<JoinSessionHandler>(); builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>(); builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
builder.Services.AddSingleton<CancelSessionHandler>(); builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>(); builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
@@ -23,4 +23,14 @@ public static class SessionCapacityRules
return !maxPlayers.HasValue || activeParticipants < maxPlayers.Value; 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);
}
} }
@@ -60,6 +60,11 @@ public static class SessionBatchRenderer
buttons.Add(new[] buttons.Add(new[]
{ {
InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"), 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($"❌ Отменить {dateTitle} (ГМ)", $"cancel_session:{session.SessionId}"),
InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}") InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}")
} }
@@ -47,7 +47,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v1.2.0</div> <div class="nav-version">v1.3.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
+1 -1
View File
@@ -1,5 +1,5 @@
/* ============================================ /* ============================================
GM-Relay Design System v1.2.0 GM-Relay Design System v1.3.0
Dark RPG Dashboard Theme Dark RPG Dashboard Theme
============================================ */ ============================================ */
@@ -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: 3, waitlistedParticipants: 1));
Assert.False(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 2, waitlistedParticipants: 0)); 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));
}
} }
@@ -42,13 +42,15 @@ public sealed class SessionBatchRendererTests
Assert.Contains("Лист ожидания (1)", text); Assert.Contains("Лист ожидания (1)", text);
Assert.Contains("Charlie", text); Assert.Contains("Charlie", text);
Assert.Contains("Bob", text); Assert.Contains("Bob", text);
Assert.Equal(2, result.Markup.InlineKeyboard.Count()); Assert.Equal(4, result.Markup.InlineKeyboard.Count());
Assert.Collection( Assert.Collection(
buttons.Select(button => button.CallbackData), buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"join_session:{firstSessionId}", 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($"cancel_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData), callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"join_session:{secondSessionId}", 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($"cancel_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData), callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"promote_waitlist:{secondSessionId}", callbackData)); callbackData => Assert.Equal($"promote_waitlist:{secondSessionId}", callbackData));