9 Commits

Author SHA1 Message Date
Toutsu 5f3516e703 fix: push registry images with docker cli
Deploy Telegram Bot / build-and-push (push) Successful in 1m7s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-04-24 18:20:15 +03:00
Toutsu 2eb7d86e48 fix: disable build provenance for gitea registry
Deploy Telegram Bot / build-and-push (push) Failing after 30s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 18:17:20 +03:00
Toutsu 3e291b0ed5 chore: restore registry PAT login
Deploy Telegram Bot / build-and-push (push) Failing after 35s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 18:11:11 +03:00
Toutsu a5ba4111cf fix: use gitea job token for registry push
Deploy Telegram Bot / build-and-push (push) Failing after 18s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 18:09:12 +03:00
Toutsu f45985041b feat: allow players to leave sessions
Deploy Telegram Bot / build-and-push (push) Failing after 39s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 17:57:13 +03:00
Toutsu 9c91057798 feat: add session capacity waitlist
Deploy Telegram Bot / build-and-push (push) Failing after 4m42s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 13:28:01 +03:00
Toutsu 675ac1226e chore: make compose config portable
Deploy Telegram Bot / build-and-push (push) Successful in 40s
Deploy Telegram Bot / deploy (push) Successful in 17s
2026-04-24 10:44:33 +03:00
Toutsu b80002aa36 refactor: unify session status model
Deploy Telegram Bot / build-and-push (push) Successful in 4m47s
Deploy Telegram Bot / deploy (push) Successful in 19s
Fixes #5
2026-04-24 10:26:45 +03:00
Toutsu bb8cbb7a40 test: cover core bot and web scenarios
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / deploy (push) Successful in 20s
2026-04-23 21:08:41 +03:00
47 changed files with 2051 additions and 384 deletions
+3
View File
@@ -8,3 +8,6 @@ TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
# Пароль для базы данных PostgreSQL # Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase POSTGRES_PASSWORD=StrongPasswordForDatabase
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
+27 -23
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.1.2 VERSION: 1.3.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -23,29 +23,33 @@ jobs:
username: toutsu username: toutsu
password: ${{ secrets.GIT_TOKEN }} password: ${{ secrets.GIT_TOKEN }}
- name: Build and push Bot - name: Build Bot image
uses: docker/build-push-action@v5 run: |
with: docker build \
context: . --label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
file: src/GmRelay.Bot/Dockerfile -f src/GmRelay.Bot/Dockerfile \
push: true -t git.codeanddice.ru/toutsu/gmrelay-bot:latest \
tags: | -t git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }} \
git.codeanddice.ru/toutsu/gmrelay-bot:latest .
git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
labels: |
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}
- name: Build and push Web - name: Push Bot image
uses: docker/build-push-action@v5 run: |
with: docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest
context: . docker push git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
file: src/GmRelay.Web/Dockerfile
push: true - name: Build Web image
tags: | run: |
git.codeanddice.ru/toutsu/gmrelay-web:latest docker build \
git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }} --label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
labels: | -f src/GmRelay.Web/Dockerfile \
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }} -t git.codeanddice.ru/toutsu/gmrelay-web:latest \
-t git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }} \
.
- name: Push Web image
run: |
docker push git.codeanddice.ru/toutsu/gmrelay-web:latest
docker push git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
# ЧАСТЬ 2: Запускаем эти образы на самом сервере # ЧАСТЬ 2: Запускаем эти образы на самом сервере
deploy: deploy:
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.1.2</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>
+17 -4
View File
@@ -4,13 +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 или Яндекс Календарь одной командой.
@@ -19,6 +22,7 @@
### 🌐 Web Dashboard (Blazor Server) ### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация). - **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов. - **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков. - **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC. - **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
@@ -65,6 +69,9 @@ TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
# Пароль для базы данных PostgreSQL # Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=ваш_надежный_пароль POSTGRES_PASSWORD=ваш_надежный_пароль
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
``` ```
*(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте. *(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте.
@@ -72,12 +79,13 @@ POSTGRES_PASSWORD=ваш_надежный_пароль
### 3. Запуск ### 3. Запуск
Выполните команду: Выполните команду:
```bash ```bash
docker compose up -d -build docker compose up -d
``` ```
Инфраструктура автоматически: Инфраструктура автоматически:
- Поднимет PostgreSQL. - Создаст локальную Docker-сеть и volume PostgreSQL, если их ещё нет.
- Поднимет PostgreSQL, доступный для контейнеров как `db:5432`.
- Запустит бота (применив миграции БД). - Запустит бота (применив миграции БД).
- Запустит веб-интерфейс (доступен по умолчанию на порту **8080** внутри контейнера). - Запустит веб-интерфейс на `http://localhost:8080` или другом порту из `GMRELAY_WEB_PORT`.
--- ---
@@ -106,9 +114,14 @@ docker compose up -d -build
Название: Легенды Берега Мечей (D&D 5e) Название: Легенды Берега Мечей (D&D 5e)
Время: 15.05.2024 19:30 Время: 15.05.2024 19:30
Время: 22.05.2024 19:00 Время: 22.05.2024 19:00
Мест: 4
Ссылка: https://discord.gg/invite-link Ссылка: https://discord.gg/invite-link
``` ```
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Другие команды ### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе. - `/listsessions` — Показать список всех актуальных игр в этой группе.
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков. - `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
+22 -18
View File
@@ -1,16 +1,15 @@
services: services:
db: db:
image: postgres:17-alpine image: postgres:17-alpine
container_name: gmrelay_db
restart: always restart: always
environment: environment:
POSTGRES_USER: gmrelay POSTGRES_USER: gmrelay
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: gmrelay_db POSTGRES_DB: gmrelay_db
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports: networks:
- "5432:5432" - gmrelay
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U gmrelay -d gmrelay_db" ] test: [ "CMD-SHELL", "pg_isready -U gmrelay -d gmrelay_db" ]
interval: 3s interval: 3s
@@ -18,35 +17,40 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.2 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.3.0
container_name: gmrelay_bot
restart: always restart: always
network_mode: host
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}" - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
networks:
- gmrelay
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.2 image: git.codeanddice.ru/toutsu/gmrelay-web:1.3.0
container_name: gmrelay_web
restart: always restart: always
network_mode: host
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}" - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME}" - "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
ports:
- "${GMRELAY_WEB_PORT:-8080}:8080"
volumes: volumes:
- web_keys:/app/dataprotection-keys - web_keys:/app/dataprotection-keys
networks:
- gmrelay
volumes: volumes:
pgdata: pgdata:
external: true name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
name: game_pgdata
web_keys: web_keys:
name: gmrelay_web_keys name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
networks:
gmrelay:
driver: bridge
@@ -1,14 +1,11 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp; namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRsvpCommand( public sealed record HandleRsvpCommand(
Guid SessionId, Guid SessionId,
long TelegramUserId, long TelegramUserId,
@@ -17,13 +14,12 @@ public sealed record HandleRsvpCommand(
long ChatId, long ChatId,
int MessageId); int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined); internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
internal sealed record SessionContext( internal sealed record SessionContext(
string Title, string Title,
DateTime ScheduledAt, DateTime ScheduledAt,
string Status,
long GmTelegramId, long GmTelegramId,
long TelegramChatId); long TelegramChatId);
@@ -33,21 +29,6 @@ internal sealed record ParticipantRsvp(
string? TelegramUsername, string? TelegramUsername,
string RsvpStatus); string RsvpStatus);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles the "Буду" / "Не смогу" callback query.
///
/// Flow:
/// 1. Validate that the user is a participant in this session
/// 2. Record or update their RSVP (idempotent)
/// 3. If declined → alert GM privately, revert session if was Confirmed
/// 4. If all non-GM players confirmed → mark session Confirmed, notify group + GM
/// 5. Update the inline keyboard to show current RSVP status
///
/// Concurrency: two simultaneous clicks on different rows don't conflict (MVCC).
/// The last EditMessage wins, which is fine — both reflect up-to-date state.
/// </summary>
public sealed class HandleRsvpHandler( public sealed class HandleRsvpHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
@@ -58,19 +39,19 @@ public sealed class HandleRsvpHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
// ── 1. Validate participant ──────────────────────────────────
var participantExists = await connection.ExecuteScalarAsync<bool>( var participantExists = await connection.ExecuteScalarAsync<bool>(
""" """
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 FROM session_participants sp SELECT 1
FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false AND sp.is_gm = false
AND sp.registration_status = @Active
) )
""", """,
new { command.SessionId, command.TelegramUserId }, new { command.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
transaction); transaction);
if (!participantExists) if (!participantExists)
@@ -82,8 +63,6 @@ public sealed class HandleRsvpHandler(
return; return;
} }
// ── 2. Record RSVP (idempotent) ─────────────────────────────
var updated = await connection.ExecuteAsync( var updated = await connection.ExecuteAsync(
""" """
UPDATE session_participants UPDATE session_participants
@@ -91,14 +70,14 @@ public sealed class HandleRsvpHandler(
responded_at = now() responded_at = now()
WHERE session_id = @SessionId WHERE session_id = @SessionId
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId) AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
AND registration_status = @Active
AND rsvp_status != @Status AND rsvp_status != @Status
""", """,
new { command.SessionId, command.TelegramUserId, command.Status }, new { command.SessionId, command.TelegramUserId, command.Status, Active = ParticipantRegistrationStatus.Active },
transaction); transaction);
if (updated == 0) if (updated == 0)
{ {
// Already in this state — just dismiss the loading spinner
var alreadyText = command.Status == RsvpStatus.Confirmed var alreadyText = command.Status == RsvpStatus.Confirmed
? "Вы уже подтвердили участие." ? "Вы уже подтвердили участие."
: "Вы уже отказались от участия."; : "Вы уже отказались от участия.";
@@ -110,11 +89,11 @@ public sealed class HandleRsvpHandler(
return; return;
} }
// ── 3. Load session context ─────────────────────────────────
var session = await connection.QuerySingleAsync<SessionContext>( var session = await connection.QuerySingleAsync<SessionContext>(
""" """
SELECT s.title, s.scheduled_at AS ScheduledAt, SELECT s.title,
s.scheduled_at AS ScheduledAt,
s.status AS Status,
g.gm_telegram_id AS GmTelegramId, g.gm_telegram_id AS GmTelegramId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId
FROM sessions s FROM sessions s
@@ -124,26 +103,27 @@ public sealed class HandleRsvpHandler(
new { command.SessionId }, new { command.SessionId },
transaction); transaction);
// ── 4. Handle decline ───────────────────────────────────────
if (command.Status == RsvpStatus.Declined) if (command.Status == RsvpStatus.Declined)
{ {
// Revert session to ConfirmationSent if it was Confirmed var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
await connection.ExecuteAsync(
""" if (decision.ShouldRevertSessionToConfirmationSent)
UPDATE sessions {
SET status = @ConfirmationSent, updated_at = now() await connection.ExecuteAsync(
WHERE id = @SessionId AND status = @Confirmed """
""", UPDATE sessions
new SET status = @ConfirmationSent, updated_at = now()
{ WHERE id = @SessionId AND status = @Confirmed
command.SessionId, """,
ConfirmationSent = SessionStatus.ConfirmationSent, new
Confirmed = SessionStatus.Confirmed {
}, command.SessionId,
transaction); ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
}
// Alert GM immediately via private message
var declinedPlayer = await connection.QuerySingleAsync<string>( var declinedPlayer = await connection.QuerySingleAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId", "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId }, new { command.TelegramUserId },
@@ -151,7 +131,6 @@ public sealed class HandleRsvpHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// Send alert outside transaction (network call)
try try
{ {
await bot.SendMessage( await bot.SendMessage(
@@ -161,38 +140,38 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
command.SessionId);
} }
await bot.AnswerCallbackQuery( await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId, callbackQueryId: command.CallbackQueryId,
text: "Вы отказались от участия.", text: decision.CallbackText,
cancellationToken: ct); cancellationToken: ct);
} }
// ── 5. Handle confirm — check if ALL confirmed ──────────────
else else
{ {
var counts = await connection.QuerySingleAsync<RsvpCounts>( var counts = await connection.QuerySingleAsync<RsvpCounts>(
""" """
SELECT SELECT
count(*) AS Total, count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed, count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
FROM session_participants FROM session_participants
WHERE session_id = @SessionId AND is_gm = false WHERE session_id = @SessionId AND is_gm = false
AND registration_status = @Active
""", """,
new new
{ {
command.SessionId, command.SessionId,
Confirmed = RsvpStatus.Confirmed, Confirmed = RsvpStatus.Confirmed,
Declined = RsvpStatus.Declined Declined = RsvpStatus.Declined,
Active = ParticipantRegistrationStatus.Active
}, },
transaction); transaction);
var allConfirmed = counts.Confirmed == counts.Total; var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
if (allConfirmed) if (decision.ShouldMarkSessionConfirmed)
{ {
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
@@ -206,9 +185,8 @@ public sealed class HandleRsvpHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
if (allConfirmed) if (decision.ShouldNotifyGroup)
{ {
// Notify group
try try
{ {
await bot.SendMessage( await bot.SendMessage(
@@ -218,11 +196,12 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
command.SessionId);
} }
}
// Notify GM privately if (decision.ShouldNotifyGm)
{
try try
{ {
await bot.SendMessage( await bot.SendMessage(
@@ -232,27 +211,20 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
command.SessionId);
} }
} }
await bot.AnswerCallbackQuery( await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId, callbackQueryId: command.CallbackQueryId,
text: "Вы подтвердили участие!", text: decision.CallbackText,
cancellationToken: ct); cancellationToken: ct);
} }
// ── 6. Update inline keyboard message ───────────────────────
await UpdateConfirmationMessage(command, session, ct); await UpdateConfirmationMessage(command, session, ct);
} }
/// <summary> private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
/// Re-renders the confirmation message with current RSVP statuses.
/// </summary>
private async Task UpdateConfirmationMessage(
HandleRsvpCommand command, SessionContext session, CancellationToken ct)
{ {
try try
{ {
@@ -260,16 +232,18 @@ public sealed class HandleRsvpHandler(
var participants = (await connection.QueryAsync<ParticipantRsvp>( var participants = (await connection.QueryAsync<ParticipantRsvp>(
""" """
SELECT p.telegram_id AS TelegramId, SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus sp.rsvp_status AS RsvpStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY sp.responded_at NULLS LAST ORDER BY sp.responded_at NULLS LAST
""", """,
new { command.SessionId })).ToList(); new { command.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList(); var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList(); var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
@@ -279,34 +253,47 @@ public sealed class HandleRsvpHandler(
{ {
$"🎲 Подтвердите участие в «{session.Title}»", $"🎲 Подтвердите участие в «{session.Title}»",
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)", $"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
"" string.Empty
}; };
foreach (var p in confirmed) foreach (var participant in confirmed)
lines.Add($" ✅ {FormatName(p)}"); {
foreach (var p in declined) lines.Add($" ✅ {FormatName(participant)}");
lines.Add($" ❌ ~~{FormatName(p)}~~"); }
foreach (var p in pending)
lines.Add($" ⏳ {FormatName(p)}");
lines.Add(""); foreach (var participant in declined)
{
lines.Add($" ❌ ~~{FormatName(participant)}~~");
}
foreach (var participant in pending)
{
lines.Add($" ⏳ {FormatName(participant)}");
}
lines.Add(string.Empty);
if (confirmed.Count == participants.Count) if (confirmed.Count == participants.Count)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})"); lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
}
else if (declined.Count > 0) else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)"); lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
}
else else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})"); lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
}
var text = string.Join("\n", lines); var text = string.Join("\n", lines);
// Keep buttons unless everyone confirmed
var replyMarkup = confirmed.Count == participants.Count var replyMarkup = confirmed.Count == participants.Count
? null ? null
: new InlineKeyboardMarkup([ : new InlineKeyboardMarkup([
[ [
InlineKeyboardButton.WithCallbackData("\u2705 Буду", $"rsvp:confirm:{command.SessionId}"), InlineKeyboardButton.WithCallbackData(" Буду", $"rsvp:confirm:{command.SessionId}"),
InlineKeyboardButton.WithCallbackData("\u274c Не смогу", $"rsvp:decline:{command.SessionId}") InlineKeyboardButton.WithCallbackData(" Не смогу", $"rsvp:decline:{command.SessionId}")
] ]
]); ]);
@@ -319,12 +306,10 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
// EditMessage can fail if message is too old or unchanged — non-critical logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}",
command.SessionId);
} }
} }
private static string FormatName(ParticipantRsvp p) => private static string FormatName(ParticipantRsvp participant) =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName; participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName;
} }
@@ -0,0 +1,42 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
internal sealed record RsvpFlowDecision(
string CallbackText,
bool ShouldAlertGm,
bool ShouldRevertSessionToConfirmationSent,
bool ShouldMarkSessionConfirmed,
bool ShouldNotifyGroup,
bool ShouldNotifyGm);
internal static class RsvpFlowRules
{
public static RsvpFlowDecision Evaluate(
string requestedStatus,
string currentSessionStatus,
int totalParticipants,
int confirmedParticipants)
{
if (requestedStatus == RsvpStatus.Declined)
{
return new RsvpFlowDecision(
CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.",
ShouldAlertGm: true,
ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed,
ShouldMarkSessionConfirmed: false,
ShouldNotifyGroup: false,
ShouldNotifyGm: false);
}
var everyoneConfirmed = confirmedParticipants == totalParticipants;
return new RsvpFlowDecision(
CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!",
ShouldAlertGm: false,
ShouldRevertSessionToConfirmationSent: false,
ShouldMarkSessionConfirmed: everyoneConfirmed,
ShouldNotifyGroup: everyoneConfirmed,
ShouldNotifyGm: everyoneConfirmed);
}
}
@@ -60,9 +60,11 @@ public sealed class SendConfirmationHandler(
p.telegram_username AS TelegramUsername p.telegram_username AS TelegramUsername
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""", """,
new { SessionId = sessionId })).ToList(); new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
if (participants.Count == 0) if (participants.Count == 0)
{ {
@@ -63,8 +63,14 @@ public sealed class SendJoinLinkHandler(
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId WHERE sp.session_id = @SessionId
AND sp.rsvp_status = @Confirmed AND sp.rsvp_status = @Confirmed
AND sp.registration_status = @Active
""", """,
new { SessionId = sessionId, Confirmed = RsvpStatus.Confirmed })).ToList(); new
{
SessionId = sessionId,
Confirmed = RsvpStatus.Confirmed,
Active = ParticipantRegistrationStatus.Active
})).ToList();
// 3. Build message with player mentions // 3. Build message with player mentions
var mentions = string.Join(", ", players.Select(p => var mentions = string.Join(", ", players.Select(p =>
@@ -48,20 +48,29 @@ public sealed class CancelSessionHandler(
} }
// 2. Отменяем сессию // 2. Отменяем сессию
await connection.ExecuteAsync("UPDATE sessions SET status = 'Cancelled' WHERE id = @Id", new { Id = command.SessionId }, transaction); await connection.ExecuteAsync(
"UPDATE sessions SET status = @Status WHERE id = @Id",
new { Id = command.SessionId, Status = SessionStatus.Cancelled },
transaction);
// 3. Загружаем весь батч для перерисовки // 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( 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", @"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 { BatchId = session.BatchId }, transaction); new { BatchId = session.BatchId }, transaction);
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId, p.display_name as DisplayName, p.telegram_username as TelegramUsername @"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 FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.responded_at ASC, p.created_at ASC", ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
new { BatchId = session.BatchId }, transaction); new { BatchId = session.BatchId }, transaction);
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
@@ -1,11 +1,9 @@
using System.Text.RegularExpressions;
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -16,47 +14,45 @@ public sealed class CreateSessionHandler(
{ {
public async Task HandleAsync(Message message, CancellationToken cancellationToken) public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{ {
var text = message.Text ?? ""; var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
string? title = null; foreach (var timeInput in parseResult.PastTimeInputs)
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
foreach (var line in text.Split('\n'))
{ {
var trimmed = line.Trim(); await botClient.SendMessage(
if (trimmed.StartsWith("Название:", StringComparison.OrdinalIgnoreCase)) message.Chat.Id,
title = trimmed["Название:".Length..].Trim(); $"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
else if (trimmed.StartsWith("Ссылка:", StringComparison.OrdinalIgnoreCase)) cancellationToken: cancellationToken);
link = trimmed["Ссылка:".Length..].Trim();
else if (trimmed.StartsWith("Время:", StringComparison.OrdinalIgnoreCase))
{
var timeStr = trimmed["Время:".Length..].Trim();
if (MoscowTime.TryParseMoscow(timeStr, out var scheduledAt))
{
if (scheduledAt > DateTimeOffset.UtcNow)
scheduledTimes.Add(scheduledAt);
else
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Дата {timeStr} находится в прошлом и будет пропущена.", cancellationToken: cancellationToken);
}
else
{
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Некорректный формат времени '{timeStr}'. Пропущено.", cancellationToken: cancellationToken);
}
}
} }
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(link) || scheduledTimes.Count == 0) foreach (var timeInput in parseResult.InvalidTimeInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
cancellationToken: cancellationToken);
}
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid)
{ {
await botClient.SendMessage( await botClient.SendMessage(
chatId: message.Chat.Id, chatId: message.Chat.Id,
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nСсылка: https://link", text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link",
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
return; return;
} }
var title = parseResult.Title!;
var link = parseResult.Link!;
var gmId = message.From!.Id; var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"); var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username; var gmUsername = message.From.Username;
var chatId = message.Chat.Id; var chatId = message.Chat.Id;
@@ -67,20 +63,24 @@ public sealed class CreateSessionHandler(
try try
{ {
// 1. Убеждаемся, что GM зарегистрирован
await connection.ExecuteAsync( await connection.ExecuteAsync(
@"INSERT INTO players (telegram_id, display_name, telegram_username) """
VALUES (@TgId, @Name, @Username) INSERT INTO players (telegram_id, display_name, telegram_username)
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username;", VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username;
""",
new { TgId = gmId, Name = gmName, Username = gmUsername }, new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction); transaction);
// 2. Убеждаемся, что Группа зарегистрирована
var groupId = await connection.ExecuteScalarAsync<Guid>( var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id) """
VALUES (@ChatId, @ChatName, @GmId) INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name VALUES (@ChatId, @ChatName, @GmId)
RETURNING id;", ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId }, new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction); transaction);
@@ -94,29 +94,38 @@ public sealed class CreateSessionHandler(
messageThreadId = topic.MessageThreadId; messageThreadId = topic.MessageThreadId;
} }
// 3. Создаем сессии в цикле с общим batch_id
var batchId = Guid.NewGuid(); var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>(); var sessions = new List<SessionBatchDto>();
foreach (var dt in scheduledTimes.OrderBy(d => d)) foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
{ {
var sessionId = await connection.ExecuteScalarAsync<Guid>( var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id) """
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId) INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players)
RETURNING id;", VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @MaxPlayers)
new { BatchId = batchId, GroupId = groupId, Title = title, Link = link, ScheduledAt = dt, ThreadId = messageThreadId }, RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
MaxPlayers = parseResult.MaxPlayers,
Status = SessionStatus.Planned
},
transaction); transaction);
sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned")); sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers));
} }
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId); logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
// 4. Отправляем сообщение в чат
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>()); var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await botClient.SendMessage( var batchMessage = await botClient.SendMessage(
chatId: chatId, chatId: chatId,
messageThreadId: messageThreadId, messageThreadId: messageThreadId,
@@ -125,12 +134,10 @@ public sealed class CreateSessionHandler(
replyMarkup: renderResult.Markup, replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
// 4b. Сохраняем message_id батч-сообщения для дальнейшего редактирования
await connection.ExecuteAsync( await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId", "UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId }); new { MsgId = batchMessage.MessageId, BatchId = batchId });
// 5. Удаляем исходное сообщение с командой /newsession, чтобы не спамить
try try
{ {
await botClient.DeleteMessage( await botClient.DeleteMessage(
@@ -2,7 +2,6 @@ using Dapper;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
@@ -18,7 +17,7 @@ public sealed record JoinSessionCommand(
int MessageId); int MessageId);
// DTOs for AOT compilation // DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title); internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
public sealed class JoinSessionHandler( public sealed class JoinSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -29,6 +28,7 @@ public sealed class JoinSessionHandler(
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
try try
{ {
@@ -41,12 +41,68 @@ public sealed class JoinSessionHandler(
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername }, new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
transaction); transaction);
// 2. Добавляем в участники сессии (статус Pending, так как за 24 часа нужно будет финальное подтверждение) // 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>(
@"SELECT batch_id as BatchId, title as Title, max_players as MaxPlayers
FROM sessions
WHERE id = @SessionId
FOR UPDATE",
new { command.SessionId },
transaction);
if (batchInfo is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
return;
}
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
"""
SELECT sp.registration_status
FROM session_participants sp
WHERE sp.session_id = @SessionId
AND sp.player_id = @PlayerId
AND sp.is_gm = false
""",
new { command.SessionId, PlayerId = playerId },
transaction);
if (existingRegistrationStatus is not null)
{
await transaction.RollbackAsync(ct);
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!"
: "Вы уже записаны!";
await bot.AnswerCallbackQuery(command.CallbackQueryId, alreadyText, cancellationToken: ct);
return;
}
var activeParticipants = 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 registrationStatus = SessionCapacityRules.DecideJoinStatus(batchInfo.MaxPlayers, activeParticipants);
// 3. Добавляем в основной состав или лист ожидания. RSVP остается Pending до финального подтверждения.
var inserted = await connection.ExecuteAsync( var inserted = await connection.ExecuteAsync(
@"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status) @"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
VALUES (@SessionId, @PlayerId, false, 'Pending') VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus)
ON CONFLICT (session_id, player_id) DO NOTHING;", ON CONFLICT (session_id, player_id) DO NOTHING;",
new { SessionId = command.SessionId, PlayerId = playerId }, new
{
command.SessionId,
PlayerId = playerId,
Pending = RsvpStatus.Pending,
RegistrationStatus = registrationStatus
},
transaction); transaction);
if (inserted == 0) if (inserted == 0)
@@ -56,26 +112,28 @@ public sealed class JoinSessionHandler(
return; return;
} }
// 3. Получаем batch_id по session_id
var batchInfo = await connection.QuerySingleAsync<JoinSessionBatchDto>(
@"SELECT batch_id as BatchId, title as Title FROM sessions WHERE id = @SessionId",
new { command.SessionId }, transaction);
// Загружаем весь батч для перерисовки // Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( 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", @"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 { BatchId = batchInfo.BatchId }, transaction); new { BatchId = batchInfo.BatchId }, transaction);
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId, p.display_name as DisplayName, p.telegram_username as TelegramUsername @"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 FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.responded_at ASC, p.created_at ASC", ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
new { BatchId = batchInfo.BatchId }, transaction); new { BatchId = batchInfo.BatchId }, transaction);
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
transactionCommitted = true;
// 4. Перерисовываем сообщение // 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
@@ -88,13 +146,23 @@ public sealed class JoinSessionHandler(
replyMarkup: renderResult.Markup, replyMarkup: renderResult.Markup,
cancellationToken: ct); cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы успешно записаны!", cancellationToken: ct); var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!";
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Ошибка при добавлении игрока к сессии"); logger.LogError(ex, "Ошибка при добавлении игрока к сессии");
await transaction.RollbackAsync(ct); if (!transactionCommitted)
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Произошла ошибка при регистрации.", cancellationToken: ct); {
await transaction.RollbackAsync(ct);
}
var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
} }
} }
} }
@@ -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);
}
}
}
@@ -0,0 +1,104 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult(
string? Title,
string? Link,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0;
}
internal static class NewSessionCommandParser
{
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
private static readonly string[] SeatLimitPrefixes =
[
"\u041c\u0435\u0441\u0442:",
"\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
];
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
int? maxPlayers = null;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
{
title = line[TitlePrefix.Length..].Trim();
continue;
}
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
{
link = line[LinkPrefix.Length..].Trim();
continue;
}
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (seatLimitPrefix is not null)
{
var seatLimitInput = line[seatLimitPrefix.Length..].Trim();
if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0)
{
maxPlayers = parsedMaxPlayers;
}
else
{
invalidSeatLimitInputs.Add(seatLimitInput);
}
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var timeInput = line[TimePrefix.Length..].Trim();
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
{
invalidTimeInputs.Add(timeInput);
continue;
}
if (scheduledAt <= nowUtc)
{
pastTimeInputs.Add(timeInput);
continue;
}
scheduledTimes.Add(scheduledAt);
}
return new NewSessionParseResult(
title,
link,
maxPlayers,
scheduledTimes,
pastTimeInputs,
invalidTimeInputs,
invalidSeatLimitInputs);
}
}
@@ -0,0 +1,185 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record PromoteWaitlistedPlayerCommand(
Guid SessionId,
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int MessageId);
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, long GmId, int? MaxPlayers);
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
public sealed class PromoteWaitlistedPlayerHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<PromoteWaitlistedPlayerHandler> logger)
{
public async Task HandleAsync(PromoteWaitlistedPlayerCommand 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<PromoteWaitlistSessionDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.max_players AS MaxPlayers,
g.gm_telegram_id AS GmId
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.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 (session.GmId != command.TelegramUserId)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
return;
}
var activeParticipants = 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);
if (waitlistedParticipants == 0)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct);
return;
}
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct);
return;
}
var promoted = await connection.QuerySingleAsync<WaitlistedParticipantDto>(
"""
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);
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);
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", 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);
}
}
}
@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
@@ -21,10 +22,10 @@ public sealed class ExportCalendarHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId WHERE g.telegram_chat_id = @ChatId
AND s.status = 'Planned' AND s.status = @Planned
AND s.scheduled_at > NOW() AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id }); new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -74,16 +74,23 @@ public sealed class DeleteSessionHandler(
// A simple way is to re-render the list: // A simple way is to re-render the list:
await using var readConnection = await dataSource.OpenConnectionAsync(ct); await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var sessions = await readConnection.QueryAsync<SessionListItemDto>( var sessions = await readConnection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount, COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
g.gm_telegram_id as GmId g.gm_telegram_id as GmId
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = command.ChatId }); new
{
ChatId = command.ChatId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -96,7 +103,11 @@ public sealed class DeleteSessionHandler(
var text = "📅 <b>Ближайшие игры:</b>\n\n"; var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var s in sessionsList) foreach (var s in sessionsList)
{ {
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Участников: {s.PlayerCount})\n"; var seats = s.MaxPlayers.HasValue
? $"{s.PlayerCount}/{s.MaxPlayers.Value}"
: s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
} }
var isGm = command.TelegramUserId == sessionsList.First().GmId; var isGm = command.TelegramUserId == sessionsList.First().GmId;
@@ -6,7 +6,7 @@ using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ListSessions; namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int PlayerCount, long GmId); internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, long GmId);
public sealed class ListSessionsHandler( public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -17,16 +17,23 @@ public sealed class ListSessionsHandler(
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<SessionListItemDto>( var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount, COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
g.gm_telegram_id as GmId g.gm_telegram_id as GmId
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id }); new
{
ChatId = message.Chat.Id,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -42,7 +49,11 @@ public sealed class ListSessionsHandler(
var text = "📅 <b>Ближайшие игры:</b>\n\n"; var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var s in sessionsList) foreach (var s in sessionsList)
{ {
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Участников: {s.PlayerCount})\n"; var seats = s.MaxPlayers.HasValue
? $"{s.PlayerCount}/{s.MaxPlayers.Value}"
: s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
} }
var isGm = message.From?.Id == sessionsList.First().GmId; var isGm = message.From?.Id == sessionsList.First().GmId;
@@ -89,9 +89,11 @@ public sealed class HandleRescheduleTimeInputHandler(
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""", """,
new { proposal.SessionId })).ToList(); new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
// 4. If no participants — reschedule immediately // 4. If no participants — reschedule immediately
if (participants.Count == 0) if (participants.Count == 0)
@@ -154,10 +156,10 @@ public sealed class HandleRescheduleTimeInputHandler(
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE sessions SET scheduled_at = @NewTime, status = 'Planned', updated_at = now() UPDATE sessions SET scheduled_at = @NewTime, status = @Status, updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId }, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -214,17 +216,20 @@ public sealed class HandleRescheduleTimeInputHandler(
await using var conn = await dataSource.OpenConnectionAsync(ct); await using var conn = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await conn.QueryAsync<SessionBatchDto>( var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", "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(); new { proposal.BatchId })).ToList();
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
""" """
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername 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 FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.responded_at ASC, p.created_at ASC ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""", """,
new { proposal.BatchId })).ToList(); new { proposal.BatchId })).ToList();
@@ -1,25 +1,20 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRescheduleVoteCommand( public sealed record HandleRescheduleVoteCommand(
Guid ProposalId, Guid ProposalId,
string Vote, // "yes" or "no" string Vote,
long TelegramUserId, long TelegramUserId,
string CallbackQueryId, string CallbackQueryId,
long ChatId, long ChatId,
int MessageId); int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record VoteProposalDto( internal sealed record VoteProposalDto(
Guid Id, Guid Id,
Guid SessionId, Guid SessionId,
@@ -32,17 +27,6 @@ internal sealed record VoteProposalDto(
int? ConfirmationMessageId, int? ConfirmationMessageId,
int? BatchMessageId); int? BatchMessageId);
internal sealed record VoteCountDto(int Total, int Approved);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles "✅ Согласен" / "❌ Против" votes on a reschedule proposal.
///
/// If anyone votes no → proposal rejected, old time stays.
/// If all vote yes → session time updated, batch message re-rendered,
/// session status reset to Planned so confirmation triggers work correctly.
/// </summary>
public sealed class HandleRescheduleVoteHandler( public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
@@ -53,12 +37,15 @@ public sealed class HandleRescheduleVoteHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Load proposal + session info
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>( var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
""" """
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.proposed_at AS ProposedAt, SELECT rp.id AS Id,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt, rp.session_id AS SessionId,
s.batch_id AS BatchId, s.status AS SessionStatus, 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.confirmation_message_id AS ConfirmationMessageId,
s.batch_message_id AS BatchMessageId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId
@@ -72,12 +59,13 @@ public sealed class HandleRescheduleVoteHandler(
if (proposal is null) if (proposal is null)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(
"Голосование уже завершено или не найдено.", cancellationToken: ct); command.CallbackQueryId,
"Голосование уже завершено или не найдено.",
cancellationToken: ct);
return; return;
} }
// 2. Verify voter is a participant of this session
var playerId = await connection.ExecuteScalarAsync<Guid?>( var playerId = await connection.ExecuteScalarAsync<Guid?>(
""" """
SELECT p.id SELECT p.id
@@ -86,29 +74,61 @@ public sealed class HandleRescheduleVoteHandler(
WHERE sp.session_id = @SessionId WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false AND sp.is_gm = false
AND sp.registration_status = @Active
""", """,
new { proposal.SessionId, command.TelegramUserId }, new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
transaction); transaction);
if (playerId is null) if (playerId is null)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(
"Вы не являетесь участником этой сессии.", cancellationToken: ct); command.CallbackQueryId,
"Вы не являетесь участником этой сессии.",
cancellationToken: ct);
return; return;
} }
// 3. Record vote (upsert) await connection.ExecuteAsync(
var inserted = await connection.ExecuteAsync(
""" """
INSERT INTO reschedule_votes (proposal_id, player_id, vote) INSERT INTO reschedule_votes (proposal_id, player_id, vote)
VALUES (@ProposalId, @PlayerId, @Vote) VALUES (@ProposalId, @PlayerId, @Vote)
ON CONFLICT (proposal_id, player_id) DO UPDATE SET vote = EXCLUDED.vote, voted_at = now() ON CONFLICT (proposal_id, player_id) DO UPDATE
SET vote = EXCLUDED.vote,
voted_at = now()
""", """,
new { command.ProposalId, PlayerId = playerId.Value, command.Vote }, new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
transaction); transaction);
// 4. Handle "no" vote — immediately reject var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
if (command.Vote == "no") ? 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
AND sp.registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
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( await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id", "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
@@ -117,12 +137,10 @@ public sealed class HandleRescheduleVoteHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// Get voter's name
var voterName = await connection.QuerySingleOrDefaultAsync<string>( var voterName = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TgId", "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { TgId = command.TelegramUserId }); new { command.TelegramUserId });
// Update voting message — show rejection
try try
{ {
await bot.EditMessageText( await bot.EditMessageText(
@@ -137,49 +155,26 @@ public sealed class HandleRescheduleVoteHandler(
logger.LogWarning(ex, "Failed to update vote message after rejection"); logger.LogWarning(ex, "Failed to update vote message after rejection");
} }
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы проголосовали против переноса.", cancellationToken: ct); await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId); logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
return; return;
} }
// 5. Handle "yes" vote — check if all approved if (decision.ShouldRescheduleSession)
var participants = (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 = (await connection.QueryAsync<Guid>(
"""
SELECT player_id FROM reschedule_votes
WHERE proposal_id = @ProposalId AND vote = 'yes'
""",
new { command.ProposalId },
transaction)).ToHashSet();
var allApproved = approvedPlayerIds.Count == participants.Count;
if (allApproved)
{ {
// 6. All approved — reschedule! var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); // ProposedAt is stored in UTC
// Update session time and reset status to Planned for fresh notification cycle
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE sessions UPDATE sessions
SET scheduled_at = @NewTime, SET scheduled_at = @NewTime,
status = 'Planned', status = @Status,
confirmation_message_id = NULL, confirmation_message_id = NULL,
link_message_id = NULL, link_message_id = NULL,
updated_at = now() updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId }, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -187,19 +182,22 @@ public sealed class HandleRescheduleVoteHandler(
new { Id = command.ProposalId }, new { Id = command.ProposalId },
transaction); transaction);
// Reset all participant RSVP to Pending for the new confirmation cycle if (decision.ShouldResetParticipantRsvps)
await connection.ExecuteAsync( {
""" await connection.ExecuteAsync(
UPDATE session_participants """
SET rsvp_status = 'Pending', responded_at = NULL UPDATE session_participants
WHERE session_id = @SessionId AND is_gm = false SET rsvp_status = 'Pending',
""", responded_at = NULL
new { proposal.SessionId }, WHERE session_id = @SessionId AND is_gm = false
transaction); AND registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
}
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// Update voting message — show approval
try try
{ {
await bot.EditMessageText( await bot.EditMessageText(
@@ -214,21 +212,24 @@ public sealed class HandleRescheduleVoteHandler(
logger.LogWarning(ex, "Failed to update vote message after approval"); logger.LogWarning(ex, "Failed to update vote message after approval");
} }
// Re-render batch message
await TryUpdateBatchMessage(proposal, ct); await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation("Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})", logger.LogInformation(
proposal.SessionId, newTime, command.ProposalId); "Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
proposal.SessionId,
newTime,
command.ProposalId);
} }
else else
{ {
// Not all voted yet — update the voting message to show progress
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage( var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title, proposal.CurrentScheduledAt, proposal.Title,
proposal.CurrentScheduledAt,
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero), new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
participants, approvedPlayerIds); participants,
approvedPlayerIds);
var keyboard = new InlineKeyboardMarkup([ var keyboard = new InlineKeyboardMarkup([
[ [
@@ -253,15 +254,9 @@ public sealed class HandleRescheduleVoteHandler(
} }
} }
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
allApproved ? "Вы подтвердили перенос! Все согласны — время обновлено." : "Вы подтвердили перенос!",
cancellationToken: ct);
} }
/// <summary>
/// Re-renders the batch schedule message to reflect the updated session time.
/// If batch_message_id is stored, edits the original message. Otherwise sends a notification.
/// </summary>
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct) private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
{ {
try try
@@ -269,23 +264,25 @@ public sealed class HandleRescheduleVoteHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>( 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", "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(); new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
""" """
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername 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 FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.responded_at ASC, p.created_at ASC ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""", """,
new { proposal.BatchId })).ToList(); new { proposal.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue) if (proposal.BatchMessageId.HasValue)
{ {
// Edit the original batch schedule message in-place
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants); var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText( await bot.EditMessageText(
@@ -298,10 +295,9 @@ public sealed class HandleRescheduleVoteHandler(
} }
else else
{ {
// Fallback for sessions created before V005 migration (no batch_message_id)
await bot.SendMessage( await bot.SendMessage(
chatId: proposal.TelegramChatId, chatId: proposal.TelegramChatId,
text: $"📢 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).", text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct); cancellationToken: ct);
} }
@@ -1,4 +1,5 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -39,9 +40,9 @@ public sealed class InitiateRescheduleHandler(
SELECT s.title AS Title, g.gm_telegram_id AS GmId SELECT s.title AS Title, g.gm_telegram_id AS GmId
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId AND s.status != 'Cancelled' WHERE s.id = @SessionId AND s.status != @Cancelled
""", """,
new { command.SessionId }); new { command.SessionId, Cancelled = SessionStatus.Cancelled });
if (session is null) if (session is null)
{ {
@@ -0,0 +1,39 @@
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal enum RescheduleVoteOutcome
{
Pending,
Rejected,
Approved
}
internal sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome,
string CallbackText,
bool ShouldRescheduleSession,
bool ShouldResetParticipantRsvps);
internal static class RescheduleVoteRules
{
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);
}
var everyoneApproved = approvedParticipants == totalParticipants;
return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
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!",
ShouldRescheduleSession: everyoneApproved,
ShouldResetParticipantRsvps: everyoneApproved);
}
}
@@ -20,6 +20,8 @@ public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler, HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler, CreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler, JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
CancelSessionHandler cancelSessionHandler, CancelSessionHandler cancelSessionHandler,
DeleteSessionHandler deleteSessionHandler, DeleteSessionHandler deleteSessionHandler,
ListSessionsHandler listSessionsHandler, ListSessionsHandler listSessionsHandler,
@@ -72,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(
@@ -85,6 +100,19 @@ public sealed class UpdateRouter(
return; return;
} }
if (action == "promote_waitlist" && parts.Length >= 2 && Guid.TryParse(parts[1], out var promoteSessionId))
{
var command = new PromoteWaitlistedPlayerCommand(
SessionId: promoteSessionId,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await promoteWaitlistedPlayerHandler.HandleAsync(command, ct);
return;
}
if (action == "delete_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var deleteSessionId)) if (action == "delete_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var deleteSessionId))
{ {
var command = new DeleteSessionCommand( var command = new DeleteSessionCommand(
@@ -192,9 +220,11 @@ public sealed class UpdateRouter(
/newsession /newsession
Название: My Game Название: My Game
Время: 15.05.2026 19:30 Время: 15.05.2026 19:30
Мест: 4
Ссылка: https://link Ссылка: https://link
/listsessions список предстоящих сессий /listsessions список предстоящих сессий
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
/help эта справка /help эта справка
""", """,
cancellationToken: ct); cancellationToken: ct);
@@ -0,0 +1,16 @@
-- Add per-session seat limits and participant waitlist support.
ALTER TABLE sessions
ADD COLUMN max_players INTEGER,
ADD CONSTRAINT ck_sessions_max_players CHECK (max_players IS NULL OR max_players > 0);
ALTER TABLE session_participants
ADD COLUMN registration_status VARCHAR(50) NOT NULL DEFAULT 'Active'
CHECK (registration_status IN ('Active', 'Waitlisted')),
ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT now();
CREATE INDEX ix_session_participants_session_registration_status
ON session_participants (session_id, registration_status);
CREATE INDEX ix_session_participants_waitlist_order
ON session_participants (session_id, created_at, id)
WHERE registration_status = 'Waitlisted';
+2
View File
@@ -54,6 +54,8 @@ 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<CancelSessionHandler>(); builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>(); builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>(); builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
@@ -12,11 +12,11 @@
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.2.0" /> <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.2.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.2.0" /> <PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.2.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,9 @@
namespace GmRelay.Shared.Domain;
public static class ParticipantRegistrationStatus
{
public const string Active = "Active";
public const string Waitlisted = "Waitlisted";
public static readonly string[] All = [Active, Waitlisted];
}
@@ -0,0 +1,36 @@
namespace GmRelay.Shared.Domain;
public static class SessionCapacityRules
{
public static string DecideJoinStatus(int? maxPlayers, int activeParticipants)
{
if (!maxPlayers.HasValue)
{
return ParticipantRegistrationStatus.Active;
}
return activeParticipants < maxPlayers.Value
? ParticipantRegistrationStatus.Active
: ParticipantRegistrationStatus.Waitlisted;
}
public static bool CanPromoteWaitlistedPlayer(int? maxPlayers, int activeParticipants, int waitlistedParticipants)
{
if (waitlistedParticipants <= 0)
{
return false;
}
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);
}
}
@@ -1,3 +1,5 @@
using System.Collections.Frozen;
namespace GmRelay.Shared.Domain; namespace GmRelay.Shared.Domain;
public static class SessionStatus public static class SessionStatus
@@ -6,4 +8,13 @@ public static class SessionStatus
public const string ConfirmationSent = "ConfirmationSent"; public const string ConfirmationSent = "ConfirmationSent";
public const string Confirmed = "Confirmed"; public const string Confirmed = "Confirmed";
public const string Cancelled = "Cancelled"; public const string Cancelled = "Cancelled";
public static IReadOnlySet<string> All { get; } =
new[] { Planned, ConfirmationSent, Confirmed, Cancelled }
.ToFrozenSet(StringComparer.Ordinal);
public static bool IsKnown(string status) => All.Contains(status);
public static bool IsCancelled(string status) =>
string.Equals(status, Cancelled, StringComparison.Ordinal);
} }
@@ -3,8 +3,8 @@ using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Shared.Rendering; namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status); public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername); public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
public static class SessionBatchRenderer public static class SessionBatchRenderer
{ {
@@ -22,10 +22,17 @@ public static class SessionBatchRenderer
foreach (var session in activeSessions) foreach (var session in activeSessions)
{ {
var sessionPlayers = participants.Where(p => p.SessionId == session.SessionId).ToList(); var sessionPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
.ToList();
var waitlistedPlayers = participants
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
.ToList();
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n"; messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
messageText += $"👥 Игроки ({sessionPlayers.Count}):\n"; messageText += session.MaxPlayers.HasValue
? $"👥 Места: {sessionPlayers.Count}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({sessionPlayers.Count}):\n";
if (sessionPlayers.Count > 0) if (sessionPlayers.Count > 0)
{ {
@@ -36,27 +43,48 @@ public static class SessionBatchRenderer
messageText += " <i>Пока никто не записался</i>\n"; messageText += " <i>Пока никто не записался</i>\n";
} }
if (session.Status == "Cancelled") if (waitlistedPlayers.Count > 0)
{
messageText += $"⏳ Лист ожидания ({waitlistedPlayers.Count}):\n";
messageText += string.Join("\n", waitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
}
if (SessionStatus.IsCancelled(session.Status))
{ {
messageText += "❌ <i>Сессия отменена</i>\n\n"; messageText += "❌ <i>Сессия отменена</i>\n\n";
} }
else if (session.Status == "RecruitmentClosed")
{
messageText += "🔒 <i>Набор завершен</i>\n\n";
}
else else
{ {
messageText += "\n"; messageText += "\n";
var dateTitle = session.ScheduledAt.FormatMoscowShort(); var dateTitle = session.ScheduledAt.FormatMoscowShort();
buttons.Add(new[] buttons.Add(new[]
{ {
InlineKeyboardButton.WithCallbackData($"✋ На {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}")
}); }
.Concat(SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, sessionPlayers.Count, waitlistedPlayers.Count)
? [InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle} (ГМ)", $"promote_waitlist:{session.SessionId}")]
: [])
.ToArray());
} }
} }
return (messageText, new InlineKeyboardMarkup(buttons)); return (messageText, new InlineKeyboardMarkup(buttons));
} }
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
{
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
{
return $"⏳ В лист ожидания {dateTitle}";
}
return $"✋ На {dateTitle}";
}
} }
@@ -47,7 +47,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v1.1.0</div> <div class="nav-version">v1.3.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -51,6 +51,12 @@
<InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" /> <InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
</div> </div>
<div class="gm-form-group">
<label class="gm-form-label">Лимит мест</label>
<InputNumber @bind-Value="model.MaxPlayers" class="gm-form-control" min="1" placeholder="Без лимита" />
<div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;"> <div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting"> <button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения") @(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
@@ -97,6 +103,7 @@
model.Title = session.Title; model.Title = session.Title;
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow(); model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink; model.JoinLink = session.JoinLink;
model.MaxPlayers = session.MaxPlayers;
} }
private async Task HandleSubmit() private async Task HandleSubmit()
@@ -115,7 +122,7 @@
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink); await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
Navigation.NavigateTo($"/group/{session!.GroupId}"); Navigation.NavigateTo($"/group/{session!.GroupId}");
} }
catch (SessionAccessDeniedException) catch (SessionAccessDeniedException)
@@ -139,5 +146,6 @@
public string Title { get; set; } = ""; public string Title { get; set; } = "";
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now; public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
public string JoinLink { get; set; } = ""; public string JoinLink { get; set; } = "";
public int? MaxPlayers { get; set; }
} }
} }
@@ -20,6 +20,13 @@
<h2>📅 Предстоящие игры</h2> <h2>📅 Предстоящие игры</h2>
</div> </div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (sessions == null) @if (sessions == null)
{ {
<div class="glass-card" style="padding: 2rem;"> <div class="glass-card" style="padding: 2rem;">
@@ -48,6 +55,7 @@
<tr> <tr>
<th>Название</th> <th>Название</th>
<th>Время (МСК)</th> <th>Время (МСК)</th>
<th>Места</th>
<th>Статус</th> <th>Статус</th>
<th>Ссылка</th> <th>Ссылка</th>
<th>Действие</th> <th>Действие</th>
@@ -59,6 +67,7 @@
<tr> <tr>
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td> <td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
<td>@session.ScheduledAt.FormatMoscow()</td> <td>@session.ScheduledAt.FormatMoscow()</td>
<td>@FormatSeats(session)</td>
<td> <td>
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span> <span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</td> </td>
@@ -69,9 +78,17 @@
</a> </a>
</td> </td>
<td> <td>
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;"> <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
✏️ Изменить <a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;">
</a> ✏️ Изменить
</a>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
</button>
}
</div>
</td> </td>
</tr> </tr>
} }
@@ -93,6 +110,10 @@
<span>🕐 Время</span> <span>🕐 Время</span>
<span style="color: var(--text-primary);">@session.ScheduledAt.FormatMoscow()</span> <span style="color: var(--text-primary);">@session.ScheduledAt.FormatMoscow()</span>
</div> </div>
<div class="session-card-row">
<span>👥 Места</span>
<span style="color: var(--text-primary);">@FormatSeats(session)</span>
</div>
<div class="session-card-row"> <div class="session-card-row">
<span>🔗 Ссылка</span> <span>🔗 Ссылка</span>
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer">Подключиться ↗</a> <a href="@session.JoinLink" target="_blank" rel="noopener noreferrer">Подключиться ↗</a>
@@ -102,6 +123,12 @@
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;"> <a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
✏️ Изменить ✏️ Изменить
</a> </a>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
</button>
}
</div> </div>
</div> </div>
} }
@@ -112,11 +139,14 @@
@code { @code {
[Parameter] public Guid GroupId { get; set; } [Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions; private List<WebSession>? sessions;
private Guid? promotingSessionId;
private long telegramId;
private string? errorMessage;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId)) if (!authState.User.TryGetTelegramId(out telegramId))
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
return; return;
@@ -129,20 +159,56 @@
} }
} }
private async Task PromoteWaitlisted(Guid sessionId)
{
errorMessage = null;
promotingSessionId = sessionId;
try
{
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
promotingSessionId = null;
}
}
private static bool CanPromote(WebSession session) =>
session.WaitlistedPlayerCount > 0 &&
(!session.MaxPlayers.HasValue || session.ActivePlayerCount < session.MaxPlayers.Value);
private static string FormatSeats(WebSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: session.ActivePlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
return session.WaitlistedPlayerCount > 0
? $"{seats} · ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private string GetStatusClass(string status) => status switch private string GetStatusClass(string status) => status switch
{ {
SessionStatus.Confirmed => "status-success", SessionStatus.Confirmed => "status-success",
SessionStatus.Cancelled => "status-danger", SessionStatus.Cancelled => "status-danger",
SessionStatus.ConfirmationSent => "status-warning", SessionStatus.ConfirmationSent => "status-warning",
"Recruiting" => "status-info", SessionStatus.Planned => "status-info",
"RecruitmentClosed" => "status-info",
_ => "status-neutral" _ => "status-neutral"
}; };
private string TranslateStatus(string status) => status switch private string TranslateStatus(string status) => status switch
{ {
"Recruiting" => "Набор",
"RecruitmentClosed" => "Набор закрыт",
SessionStatus.Planned => "Запланировано", SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждём подтверждения", SessionStatus.ConfirmationSent => "Ждём подтверждения",
SessionStatus.Confirmed => "Подтверждено", SessionStatus.Confirmed => "Подтверждено",
@@ -26,7 +26,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null; return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
} }
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink) public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{ {
var session = await GetSessionForGmAsync(sessionId, gmId); var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null) if (session is null)
@@ -34,7 +34,18 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
throw new SessionAccessDeniedException(sessionId, gmId); throw new SessionAccessDeniedException(sessionId, gmId);
} }
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink); await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers);
}
public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
}
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId);
} }
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId) private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
+2 -1
View File
@@ -6,5 +6,6 @@ public interface ISessionStore
Task<WebGameGroup?> GetGroupAsync(Guid groupId); Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId); Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId); Task<WebSession?> GetSessionAsync(Guid sessionId);
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink); Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers);
Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId);
} }
+185 -12
View File
@@ -7,7 +7,21 @@ using Telegram.Bot;
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId); public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId);
public sealed record WebSession(Guid Id, Guid GroupId, string Title, DateTime ScheduledAt, string Status, string JoinLink, Guid BatchId, int? BatchMessageId, long TelegramChatId); public sealed record WebSession(
Guid Id,
Guid GroupId,
string Title,
DateTime ScheduledAt,
string Status,
string JoinLink,
Guid BatchId,
int? BatchMessageId,
long TelegramChatId,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount);
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
public sealed class SessionService( public sealed class SessionService(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -36,12 +50,34 @@ public sealed class SessionService(
return (await conn.QueryAsync<WebSession>( return (await conn.QueryAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Active
) active_counts ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
) waitlist_counts ON true
WHERE s.group_id = @GroupId AND s.scheduled_at > now() - interval '4 hours' WHERE s.group_id = @GroupId AND s.scheduled_at > now() - interval '4 hours'
ORDER BY s.scheduled_at", ORDER BY s.scheduled_at",
new { GroupId = groupId })).ToList(); new
{
GroupId = groupId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
})).ToList();
} }
public async Task<WebSession?> GetSessionAsync(Guid sessionId) public async Task<WebSession?> GetSessionAsync(Guid sessionId)
@@ -50,14 +86,36 @@ public sealed class SessionService(
return await conn.QuerySingleOrDefaultAsync<WebSession>( return await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Active
) active_counts ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
) waitlist_counts ON true
WHERE s.id = @SessionId", WHERE s.id = @SessionId",
new { SessionId = sessionId }); new
{
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
} }
public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink) public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync(); await using var transaction = await conn.BeginTransactionAsync();
@@ -65,7 +123,10 @@ public sealed class SessionService(
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>( var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id AND s.group_id = @GroupId", WHERE s.id = @Id AND s.group_id = @GroupId",
@@ -82,9 +143,18 @@ public sealed class SessionService(
SET title = @Title, SET title = @Title,
scheduled_at = @ScheduledAt, scheduled_at = @ScheduledAt,
join_link = @JoinLink, join_link = @JoinLink,
max_players = @MaxPlayers,
updated_at = now() updated_at = now()
WHERE id = @Id AND group_id = @GroupId", WHERE id = @Id AND group_id = @GroupId",
new { Id = sessionId, GroupId = groupId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink }, new
{
Id = sessionId,
GroupId = groupId,
Title = title,
ScheduledAt = scheduledAt,
JoinLink = joinLink,
MaxPlayers = maxPlayers
},
transaction); transaction);
if (updatedRows == 0) if (updatedRows == 0)
@@ -102,7 +172,9 @@ public sealed class SessionService(
var timeChanged = oldSession.ScheduledAt != scheduledAt; var timeChanged = oldSession.ScheduledAt != scheduledAt;
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" + var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
$"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" + $"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" +
$"📅 Время: <b>{scheduledAt.FormatMoscow()}</b> (МСК)" + (timeChanged ? " (изменено)" : ""); $"📅 Время: <b>{scheduledAt.FormatMoscow()}</b> (МСК)" + (timeChanged ? " (изменено)" : "") +
"\n" +
$"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>";
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
@@ -112,6 +184,104 @@ public sealed class SessionService(
} }
} }
public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
FOR UPDATE",
new { SessionId = sessionId, GroupId = groupId },
transaction);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, 0);
}
var activeParticipants = await conn.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM session_participants
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
var waitlistedParticipants = await conn.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM session_participants
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Waitlisted
""",
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
{
throw new InvalidOperationException(waitlistedParticipants == 0
? "Лист ожидания пуст."
: "Нет свободных мест для повышения игрока.");
}
var promoted = await conn.QuerySingleAsync<WebPromotedParticipantDto>(
"""
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 { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
await conn.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);
await transaction.CommitAsync();
await bot.SendMessage(
session.TelegramChatId,
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
}
private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title) private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title)
{ {
try try
@@ -119,16 +289,19 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var sessions = (await conn.QueryAsync<SessionBatchDto>( var sessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", "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 { BatchId = batchId })).ToList(); new { BatchId = batchId })).ToList();
var participants = (await conn.QueryAsync<ParticipantBatchDto>( var participants = (await conn.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername @"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 FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.responded_at ASC, p.created_at ASC", ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
new { BatchId = batchId })).ToList(); new { BatchId = batchId })).ToList();
var renderResult = SessionBatchRenderer.Render(title, sessions, participants); var renderResult = SessionBatchRenderer.Render(title, sessions, participants);
+1 -1
View File
@@ -1,5 +1,5 @@
/* ============================================ /* ============================================
GM-Relay Design System v1.1.0 GM-Relay Design System v1.3.0
Dark RPG Dashboard Theme Dark RPG Dashboard Theme
============================================ */ ============================================ */
@@ -0,0 +1,75 @@
using System.Reflection;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Domain;
public sealed class SessionStatusTests
{
[Fact]
public void All_ShouldContainOnlyCanonicalSessionStatuses()
{
var allProperty = typeof(SessionStatus).GetProperty(
"All",
BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(allProperty);
var allStatusValues = Assert.IsAssignableFrom<IReadOnlySet<string>>(allProperty.GetValue(null));
var expectedStatusValues = new[]
{
SessionStatus.Planned,
SessionStatus.ConfirmationSent,
SessionStatus.Confirmed,
SessionStatus.Cancelled
}
.Order(StringComparer.Ordinal);
Assert.Equal(expectedStatusValues, allStatusValues.Order(StringComparer.Ordinal));
}
[Fact]
public void ProductionSources_ShouldNotReferenceLegacySessionStatuses()
{
var repositoryRoot = FindRepositoryRoot();
var productionFiles = Directory.EnumerateFiles(repositoryRoot, "*.*", SearchOption.AllDirectories)
.Where(path => IsProductionSource(path))
.ToList();
var legacyStatuses = new[] { "Recruit" + "ing", "Recruitment" + "Closed" };
var offenders = productionFiles
.SelectMany(path => legacyStatuses
.Where(status => File.ReadAllText(path).Contains(status, StringComparison.Ordinal))
.Select(status => $"{Path.GetRelativePath(repositoryRoot, path)} contains {status}"))
.ToList();
Assert.Empty(offenders);
}
private static bool IsProductionSource(string path)
{
var extension = Path.GetExtension(path);
var separator = Path.DirectorySeparatorChar;
return path.Contains($"{separator}src{separator}", StringComparison.Ordinal)
&& !path.Contains($"{separator}bin{separator}", StringComparison.Ordinal)
&& !path.Contains($"{separator}obj{separator}", StringComparison.Ordinal)
&& extension is ".cs" or ".razor" or ".sql";
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "GM-Relay.slnx")))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Could not find repository root.");
}
}
@@ -0,0 +1,50 @@
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Features.Confirmation.HandleRsvp;
public sealed class RsvpFlowRulesTests
{
[Fact]
public void Evaluate_ShouldRevertAndAlert_WhenConfirmedSessionGetsDecline()
{
var decision = RsvpFlowRules.Evaluate(
RsvpStatus.Declined,
SessionStatus.Confirmed,
totalParticipants: 3,
confirmedParticipants: 2);
Assert.True(decision.ShouldAlertGm);
Assert.True(decision.ShouldRevertSessionToConfirmationSent);
Assert.False(decision.ShouldMarkSessionConfirmed);
Assert.Equal("Вы отказались от участия.", decision.CallbackText);
}
[Fact]
public void Evaluate_ShouldMarkConfirmed_WhenLastParticipantConfirms()
{
var decision = RsvpFlowRules.Evaluate(
RsvpStatus.Confirmed,
SessionStatus.ConfirmationSent,
totalParticipants: 3,
confirmedParticipants: 3);
Assert.True(decision.ShouldMarkSessionConfirmed);
Assert.True(decision.ShouldNotifyGroup);
Assert.True(decision.ShouldNotifyGm);
}
[Fact]
public void Evaluate_ShouldKeepWaiting_WhenNotEveryoneConfirmed()
{
var decision = RsvpFlowRules.Evaluate(
RsvpStatus.Confirmed,
SessionStatus.ConfirmationSent,
totalParticipants: 4,
confirmedParticipants: 2);
Assert.False(decision.ShouldMarkSessionConfirmed);
Assert.False(decision.ShouldNotifyGroup);
Assert.False(decision.ShouldNotifyGm);
}
}
@@ -0,0 +1,88 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class NewSessionCommandParserTests
{
[Fact]
public void Parse_ShouldExtractTitleLinkAndUpcomingTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Время: 01.05.2026 20:00
Мест: 4
Ссылка: https://example.test/room
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("Curse of Strahd", result.Title);
Assert.Equal("https://example.test/room", result.Link);
Assert.Equal(4, result.MaxPlayers);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
Assert.Empty(result.PastTimeInputs);
Assert.Empty(result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldCollectPastAndInvalidTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
Название: Delta Green
Время: 20.04.2026 19:30
Время: 31.04.2026 19:30
Время: 25.04.2026 18:00
Ссылка: https://example.test/dg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Single(result.ScheduledTimes);
Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs);
Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.Link);
}
[Fact]
public void Parse_ShouldCollectInvalidSeatLimit()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
Мест: 0
Ссылка: https://example.test/blades
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.MaxPlayers);
Assert.Equal(["0"], result.InvalidSeatLimitInputs);
}
}
@@ -0,0 +1,53 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class SessionCapacityRulesTests
{
[Fact]
public void DecideJoinStatus_ShouldReturnActive_WhenSessionHasFreeSeats()
{
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 3, activeParticipants: 2);
Assert.Equal(ParticipantRegistrationStatus.Active, status);
}
[Fact]
public void DecideJoinStatus_ShouldReturnWaitlisted_WhenSessionReachedLimit()
{
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 2, activeParticipants: 2);
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
}
[Fact]
public void CanPromoteWaitlistedPlayer_ShouldRequireWaitlistAndFreeSeat()
{
Assert.True(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 2, waitlistedParticipants: 1));
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));
}
}
@@ -0,0 +1,32 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleTimeInputHandlerTests
{
[Fact]
public void BuildVotingMessage_ShouldShowApprovedAndPendingParticipants()
{
var approvedId = Guid.NewGuid();
var pendingId = Guid.NewGuid();
var currentTime = new DateTime(2026, 4, 25, 16, 30, 0, DateTimeKind.Utc);
var newTime = new DateTimeOffset(2026, 4, 26, 17, 0, 0, TimeSpan.Zero);
var participants = new List<VoteParticipantDto>
{
new(approvedId, "Alice", "alice"),
new(pendingId, "Bob", null)
};
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage(
"Shadowrun",
currentTime,
newTime,
participants,
[approvedId]);
Assert.Contains("Shadowrun", text);
Assert.Contains("✅ @alice", text);
Assert.Contains("⏳ Bob", text);
Assert.Contains("Голоса: 1/2 ✅", text);
}
}
@@ -0,0 +1,36 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class RescheduleVoteRulesTests
{
[Fact]
public void Evaluate_ShouldReject_WhenParticipantVotesNo()
{
var decision = RescheduleVoteRules.Evaluate("no", totalParticipants: 4, approvedParticipants: 3);
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
Assert.False(decision.ShouldRescheduleSession);
Assert.Equal("Вы проголосовали против переноса.", decision.CallbackText);
}
[Fact]
public void Evaluate_ShouldApprove_WhenEveryoneVotedYes()
{
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 3, approvedParticipants: 3);
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
Assert.True(decision.ShouldRescheduleSession);
Assert.True(decision.ShouldResetParticipantRsvps);
}
[Fact]
public void Evaluate_ShouldStayPending_WhileVotesOutstanding()
{
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 5, approvedParticipants: 2);
Assert.Equal(RescheduleVoteOutcome.Pending, decision.Outcome);
Assert.False(decision.ShouldRescheduleSession);
Assert.False(decision.ShouldResetParticipantRsvps);
}
}
@@ -0,0 +1,58 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Rendering;
public sealed class SessionBatchRendererTests
{
[Fact]
public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
var cancelledSessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
};
var result = SessionBatchRenderer.Render("Campaign", sessions, participants);
var text = result.Text;
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
var firstIndex = text.IndexOf(sessions[2].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
var secondIndex = text.IndexOf(sessions[0].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
var thirdIndex = text.IndexOf(sessions[1].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
Assert.Contains("Campaign", text);
Assert.True(firstIndex < secondIndex);
Assert.True(secondIndex < thirdIndex);
Assert.Contains("Места: 0/2", text);
Assert.Contains("Места: 1/4", text);
Assert.Contains("@alice", text);
Assert.Contains("Лист ожидания (1)", text);
Assert.Contains("Charlie", text);
Assert.Contains("Bob", text);
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));
}
}
@@ -16,7 +16,7 @@ public sealed class AuthorizedSessionServiceTests
], ],
sessions: sessions:
[ [
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var service = new AuthorizedSessionService(store);
@@ -56,7 +56,7 @@ public sealed class AuthorizedSessionServiceTests
], ],
sessions: sessions:
[ [
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var service = new AuthorizedSessionService(store);
@@ -66,6 +66,27 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal(sessionId, session.Id); Assert.Equal(sessionId, session.Id);
} }
[Fact]
public async Task GetSessionForGmAsync_ReturnsNull_WhenSessionBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
],
sessions:
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var session = await service.GetSessionForGmAsync(sessionId, 1001L);
Assert.Null(session);
}
[Fact] [Fact]
public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm() public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm()
{ {
@@ -78,11 +99,11 @@ public sealed class AuthorizedSessionServiceTests
], ],
sessions: sessions:
[ [
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var service = new AuthorizedSessionService(store);
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b"); var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action); await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateCalled); Assert.False(store.UpdateCalled);
@@ -102,11 +123,11 @@ public sealed class AuthorizedSessionServiceTests
], ],
sessions: sessions:
[ [
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var service = new AuthorizedSessionService(store);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b"); await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5);
Assert.True(store.UpdateCalled); Assert.True(store.UpdateCalled);
Assert.Equal(groupId, store.LastUpdatedGroupId); Assert.Equal(groupId, store.LastUpdatedGroupId);
@@ -114,6 +135,31 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal("Updated", store.LastUpdatedTitle); Assert.Equal("Updated", store.LastUpdatedTitle);
Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt); Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt);
Assert.Equal("https://example.test/b", store.LastUpdatedJoinLink); Assert.Equal("https://example.test/b", store.LastUpdatedJoinLink);
Assert.Equal(5, store.LastUpdatedMaxPlayers);
}
[Fact]
public async Task PromoteWaitlistedPlayerForGmAsync_PromotesOwnedSession()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1)
]);
var service = new AuthorizedSessionService(store);
await service.PromoteWaitlistedPlayerForGmAsync(sessionId, gmId);
Assert.True(store.PromoteCalled);
Assert.Equal(groupId, store.LastPromotedGroupId);
Assert.Equal(sessionId, store.LastPromotedSessionId);
} }
private sealed class FakeSessionStore( private sealed class FakeSessionStore(
@@ -124,11 +170,15 @@ public sealed class AuthorizedSessionServiceTests
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? []; private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
public bool UpdateCalled { get; private set; } public bool UpdateCalled { get; private set; }
public bool PromoteCalled { get; private set; }
public Guid? LastUpdatedSessionId { get; private set; } public Guid? LastUpdatedSessionId { get; private set; }
public Guid? LastUpdatedGroupId { get; private set; } public Guid? LastUpdatedGroupId { get; private set; }
public string? LastUpdatedTitle { get; private set; } public string? LastUpdatedTitle { get; private set; }
public DateTime? LastUpdatedScheduledAt { get; private set; } public DateTime? LastUpdatedScheduledAt { get; private set; }
public string? LastUpdatedJoinLink { get; private set; } public string? LastUpdatedJoinLink { get; private set; }
public int? LastUpdatedMaxPlayers { get; private set; }
public Guid? LastPromotedSessionId { get; private set; }
public Guid? LastPromotedGroupId { get; private set; }
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) => public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList()); Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList());
@@ -148,7 +198,7 @@ public sealed class AuthorizedSessionServiceTests
return Task.FromResult(session); return Task.FromResult(session);
} }
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink) public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{ {
UpdateCalled = true; UpdateCalled = true;
LastUpdatedSessionId = sessionId; LastUpdatedSessionId = sessionId;
@@ -156,6 +206,15 @@ public sealed class AuthorizedSessionServiceTests
LastUpdatedTitle = title; LastUpdatedTitle = title;
LastUpdatedScheduledAt = scheduledAt; LastUpdatedScheduledAt = scheduledAt;
LastUpdatedJoinLink = joinLink; LastUpdatedJoinLink = joinLink;
LastUpdatedMaxPlayers = maxPlayers;
return Task.CompletedTask;
}
public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
{
PromoteCalled = true;
LastPromotedSessionId = sessionId;
LastPromotedGroupId = groupId;
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
@@ -0,0 +1,109 @@
using System.Security.Cryptography;
using System.Text;
using GmRelay.Web.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
namespace GmRelay.Bot.Tests.Web;
public sealed class TelegramAuthServiceTests
{
[Fact]
public void Verify_ShouldAcceptValidTelegramPayload()
{
const string botToken = "test-bot-token";
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var query = CreateQueryCollection(
botToken,
new Dictionary<string, string>
{
["auth_date"] = authDate,
["first_name"] = "Ada",
["id"] = "424242",
["last_name"] = "Lovelace",
["username"] = "ada"
});
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
Assert.Equal("Ada Lovelace", name);
}
[Fact]
public void Verify_ShouldRejectTamperedHash()
{
const string botToken = "test-bot-token";
var values = new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["first_name"] = "Ada",
["id"] = "424242"
};
var query = CreateQueryCollection(botToken, values);
var invalidQuery = new QueryCollection(new Dictionary<string, StringValues>(query.ToDictionary(
pair => pair.Key,
pair => pair.Value))
{
["hash"] = "00"
});
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(invalidQuery, out _, out _);
Assert.False(verified);
}
[Fact]
public void Verify_ShouldRejectExpiredPayload()
{
const string botToken = "test-bot-token";
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString();
var query = CreateQueryCollection(
botToken,
new Dictionary<string, string>
{
["auth_date"] = expiredAuthDate,
["first_name"] = "Ada",
["id"] = "424242"
});
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out _, out _);
Assert.False(verified);
}
private static IConfiguration CreateConfiguration(string botToken) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Telegram:BotToken"] = botToken
})
.Build();
private static QueryCollection CreateQueryCollection(string botToken, Dictionary<string, string> values)
{
var hash = ComputeTelegramHash(botToken, values);
var queryValues = values.ToDictionary(
pair => pair.Key,
pair => new StringValues(pair.Value));
queryValues["hash"] = new StringValues(hash);
return new QueryCollection(queryValues);
}
private static string ComputeTelegramHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}