11 Commits

Author SHA1 Message Date
Toutsu a1ec688ec8 feat: add multi-option reschedule voting
Deploy Telegram Bot / build-and-push (push) Successful in 3m44s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 14:58:32 +03:00
Toutsu 2529df4157 feat: support co-gm group delegation
Deploy Telegram Bot / build-and-push (push) Successful in 3m51s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 14:27:16 +03:00
Toutsu a8f2b10956 feat: send personal player notifications
Deploy Telegram Bot / build-and-push (push) Successful in 3m36s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 10:11:11 +03:00
Toutsu 3228e77c7f fix: improve select dropdown contrast
Deploy Telegram Bot / build-and-push (push) Successful in 3m29s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-27 09:46:40 +03:00
Toutsu 621ef553e7 feat: add web batch bulk operations
Deploy Telegram Bot / build-and-push (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 09:31:51 +03:00
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
54 changed files with 4470 additions and 484 deletions
+27 -23
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.1.5
VERSION: 1.7.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -23,29 +23,33 @@ jobs:
username: toutsu
password: ${{ secrets.GIT_TOKEN }}
- name: Build and push Bot
uses: docker/build-push-action@v5
with:
context: .
file: src/GmRelay.Bot/Dockerfile
push: true
tags: |
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 Bot image
run: |
docker build \
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
-f src/GmRelay.Bot/Dockerfile \
-t git.codeanddice.ru/toutsu/gmrelay-bot:latest \
-t git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }} \
.
- name: Build and push Web
uses: docker/build-push-action@v5
with:
context: .
file: src/GmRelay.Web/Dockerfile
push: true
tags: |
git.codeanddice.ru/toutsu/gmrelay-web:latest
git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
labels: |
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}
- name: Push Bot image
run: |
docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest
docker push git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
- name: Build Web image
run: |
docker build \
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
-f src/GmRelay.Web/Dockerfile \
-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: Запускаем эти образы на самом сервере
deploy:
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.1.5</Version>
<Version>1.7.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+43 -4
View File
@@ -4,21 +4,30 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.7.0`.
---
## ✨ Ключевые возможности
### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
- **✋ Интерактивная запись**: Игроки записываются на конкретные даты нажатием одной кнопки.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram.
- **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант.
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
@@ -96,7 +105,7 @@ docker compose up -d
* `Закрепление сообщений` — рекомендуется.
> [!TIP]
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе.
> Owner группы определяется по первому человеку, который создал сессию в этой группе. Owner может назначать co-GM в Web Dashboard; owner и co-GM могут управлять сессиями через кнопки бота и веб-интерфейс.
---
@@ -110,12 +119,42 @@ docker compose up -d
Название: Легенды Берега Мечей (D&D 5e)
Время: 15.05.2024 19:30
Время: 22.05.2024 19:00
Мест: 4
Ссылка: https://discord.gg/invite-link
```
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Делегирование управления
На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM.
### Перенос сессии голосованием
Owner или co-GM нажимает кнопку `⏰ Перенести` у нужной сессии и отправляет в чат 2-3 варианта нового времени вместе с дедлайном:
```text
25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00
```
Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним.
### Bulk-операции в Web Dashboard
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут:
- обновить общий `title` и `link` сразу у всех сессий batch;
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
- клонировать batch на следующую неделю или следующий календарный месяц.
После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков.
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе.
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
- `⏰ Перенести` в сообщении расписания — Запустить голосование по 2-3 вариантам нового времени.
- `/deletesession` — Удалить сессию.
- `/exportcalendar` — Получить `.ics` файл с играми.
- `/help` — Справка по формату.
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.5
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.7.0
restart: always
depends_on:
db:
@@ -29,7 +29,7 @@ services:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.5
image: git.codeanddice.ru/toutsu/gmrelay-web:1.7.0
restart: always
depends_on:
db:
@@ -48,9 +48,10 @@ public sealed class HandleRsvpHandler(
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
)
""",
new { command.SessionId, command.TelegramUserId },
new { command.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
transaction);
if (!participantExists)
@@ -69,9 +70,10 @@ public sealed class HandleRsvpHandler(
responded_at = now()
WHERE session_id = @SessionId
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
AND registration_status = @Active
AND rsvp_status != @Status
""",
new { command.SessionId, command.TelegramUserId, command.Status },
new { command.SessionId, command.TelegramUserId, command.Status, Active = ParticipantRegistrationStatus.Active },
transaction);
if (updated == 0)
@@ -156,12 +158,14 @@ public sealed class HandleRsvpHandler(
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
FROM session_participants
WHERE session_id = @SessionId AND is_gm = false
AND registration_status = @Active
""",
new
{
command.SessionId,
Confirmed = RsvpStatus.Confirmed,
Declined = RsvpStatus.Declined
Declined = RsvpStatus.Declined,
Active = ParticipantRegistrationStatus.Active
},
transaction);
@@ -234,10 +238,12 @@ public sealed class HandleRsvpHandler(
sp.rsvp_status AS RsvpStatus
FROM session_participants sp
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
""",
new { command.SessionId })).ToList();
new { command.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
@@ -13,7 +14,8 @@ internal sealed record SessionInfo(
string Title,
DateTime ScheduledAt,
Guid GroupId,
long TelegramChatId);
long TelegramChatId,
string NotificationMode);
internal sealed record ParticipantInfo(
long TelegramId,
@@ -29,6 +31,7 @@ internal sealed record ParticipantInfo(
public sealed class SendConfirmationHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<SendConfirmationHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
@@ -39,7 +42,8 @@ public sealed class SendConfirmationHandler(
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
"""
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
g.telegram_chat_id AS TelegramChatId
g.telegram_chat_id AS TelegramChatId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.status = @Planned
@@ -60,9 +64,11 @@ public sealed class SendConfirmationHandler(
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
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)
{
@@ -113,6 +119,26 @@ public sealed class SendConfirmationHandler(
MessageId = message.MessageId
});
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var directText = $"""
🎲 <b>Подтвердите участие в игре</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
Ответьте кнопкой в групповом сообщении расписания.
""";
await directSender.SendAsync(
participants.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
directText,
"confirmation",
sessionId,
ct);
}
logger.LogInformation(
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
sessionId, session.Title, message.MessageId);
@@ -0,0 +1,41 @@
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Features.Notifications;
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
public sealed class DirectSessionNotificationSender(
ITelegramBotClient bot,
ILogger<DirectSessionNotificationSender> logger)
{
public async Task SendAsync(
IEnumerable<DirectNotificationRecipient> recipients,
string htmlText,
string notificationKind,
Guid sessionId,
CancellationToken ct)
{
foreach (var recipient in recipients)
{
try
{
await bot.SendMessage(
chatId: recipient.TelegramId,
text: htmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to send {NotificationKind} DM for session {SessionId} to player {TelegramId} ({DisplayName})",
notificationKind,
sessionId,
recipient.TelegramId,
recipient.DisplayName);
}
}
}
}
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
@@ -12,7 +13,8 @@ internal sealed record JoinLinkSession(
string Title,
string JoinLink,
DateTime ScheduledAt,
long TelegramChatId);
long TelegramChatId,
string NotificationMode);
internal sealed record ConfirmedPlayer(
long TelegramId,
@@ -28,6 +30,7 @@ internal sealed record ConfirmedPlayer(
public sealed class SendJoinLinkHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<SendJoinLinkHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
@@ -38,7 +41,8 @@ public sealed class SendJoinLinkHandler(
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSession>(
"""
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
g.telegram_chat_id AS TelegramChatId
g.telegram_chat_id AS TelegramChatId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
@@ -63,8 +67,14 @@ public sealed class SendJoinLinkHandler(
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
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
var mentions = string.Join(", ", players.Select(p =>
@@ -96,6 +106,24 @@ public sealed class SendJoinLinkHandler(
""",
new { SessionId = sessionId, MessageId = message.MessageId });
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var directText = $"""
🎮 <b>Игра начинается через 5 минут</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
""";
await directSender.SendAsync(
players.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
directText,
"join-link",
sessionId,
ct);
}
logger.LogInformation(
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
sessionId, session.Title, message.MessageId);
@@ -0,0 +1,97 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
internal sealed record OneHourReminderSession(
Guid Id,
string Title,
string JoinLink,
DateTime ScheduledAt,
string NotificationMode);
public sealed class SendOneHourReminderHandler(
NpgsqlDataSource dataSource,
DirectSessionNotificationSender directSender,
ILogger<SendOneHourReminderHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSession>(
"""
SELECT id,
title,
join_link AS JoinLink,
scheduled_at AS ScheduledAt,
notification_mode AS NotificationMode
FROM sessions
WHERE id = @SessionId
AND status IN (@Confirmed, @ConfirmationSent)
AND one_hour_reminder_processed_at IS NULL
""",
new
{
SessionId = sessionId,
Confirmed = SessionStatus.Confirmed,
ConfirmationSent = SessionStatus.ConfirmationSent
});
if (session is null)
{
logger.LogWarning("Session {SessionId} not eligible for one-hour reminder", sessionId);
return;
}
var recipients = (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.telegram_id AS TelegramId,
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 = @Active
AND sp.rsvp_status != @Declined
""",
new
{
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Declined = RsvpStatus.Declined
})).ToList();
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages() && recipients.Count > 0)
{
var text = $"""
⏰ <b>Игра начнётся примерно через 1 час</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
""";
await directSender.SendAsync(recipients, text, "one-hour-reminder", session.Id, ct);
}
await connection.ExecuteAsync(
"""
UPDATE sessions
SET one_hour_reminder_processed_at = now(),
updated_at = now()
WHERE id = @SessionId
AND one_hour_reminder_processed_at IS NULL
""",
new { SessionId = sessionId });
logger.LogInformation(
"One-hour reminder processed for session {SessionId} ({Title}) with mode {NotificationMode}",
sessionId,
session.Title,
session.NotificationMode);
}
}
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -15,11 +16,12 @@ public sealed record CancelSessionCommand(
int MessageId);
// DTOs for AOT compilation
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId);
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, bool CanManage, string NotificationMode);
public sealed class CancelSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<CancelSessionHandler> logger)
{
public async Task HandleAsync(CancelSessionCommand command, CancellationToken ct)
@@ -27,13 +29,23 @@ public sealed class CancelSessionHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Проверяем, что запрос делает ГМ данной сессии
// 1. Проверяем, что запрос делает управляющий данной группы.
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.notification_mode AS NotificationMode,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId",
new { command.SessionId }, transaction);
WHERE s.id = @SessionId
""",
new { command.SessionId, command.TelegramUserId }, transaction);
if (session == null)
{
@@ -41,9 +53,9 @@ public sealed class CancelSessionHandler(
return;
}
if (session.GmId != command.TelegramUserId)
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может отменять сессию.", showAlert: true, cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", showAlert: true, cancellationToken: ct);
return;
}
@@ -55,18 +67,37 @@ public sealed class CancelSessionHandler(
// 3. Загружаем весь батч для перерисовки
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);
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
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.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);
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 4. Перерисовываем сообщение
@@ -86,6 +117,17 @@ public sealed class CancelSessionHandler(
// Опционально: написать отдельное сообщение в чат
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
directRecipients,
$"❌ <b>Сессия отменена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>",
"session-cancelled",
command.SessionId,
ct);
}
}
catch (Exception ex)
{
@@ -7,6 +7,8 @@ using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient,
@@ -32,11 +34,19 @@ public sealed class CreateSessionHandler(
cancellationToken: cancellationToken);
}
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid)
{
await botClient.SendMessage(
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);
return;
}
@@ -66,16 +76,64 @@ public sealed class CreateSessionHandler(
new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction);
var groupId = await connection.ExecuteScalarAsync<Guid>(
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
SELECT g.id AS GroupId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND p.telegram_id = @GmId
) AS CanManage
FROM game_groups g
WHERE g.telegram_chat_id = @ChatId
""",
new { ChatId = chatId, GmId = gmId },
transaction);
Guid groupId;
if (existingGroup is null)
{
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction);
await connection.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.telegram_id = @GmId
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
}
else
{
if (!existingGroup.CanManage)
{
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
cancellationToken: cancellationToken);
return;
}
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
new { ChatName = chatTitle, GroupId = groupId },
transaction);
}
int? messageThreadId = null;
if (message.Chat.IsForum)
{
@@ -93,8 +151,8 @@ public sealed class CreateSessionHandler(
{
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, @Status, @ThreadId)
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @MaxPlayers)
RETURNING id;
""",
new
@@ -105,11 +163,12 @@ public sealed class CreateSessionHandler(
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
MaxPlayers = parseResult.MaxPlayers,
Status = SessionStatus.Planned
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned));
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers));
}
await transaction.CommitAsync(cancellationToken);
@@ -2,7 +2,6 @@ using Dapper;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
@@ -18,7 +17,7 @@ public sealed record JoinSessionCommand(
int MessageId);
// 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(
NpgsqlDataSource dataSource,
@@ -29,6 +28,7 @@ public sealed class JoinSessionHandler(
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
try
{
@@ -41,12 +41,68 @@ public sealed class JoinSessionHandler(
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
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(
@"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status)
VALUES (@SessionId, @PlayerId, false, 'Pending')
@"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus)
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);
if (inserted == 0)
@@ -56,26 +112,28 @@ public sealed class JoinSessionHandler(
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>(
@"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);
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
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.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);
await transaction.CommitAsync(ct);
transactionCommitted = true;
// 4. Перерисовываем сообщение
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
@@ -88,13 +146,23 @@ public sealed class JoinSessionHandler(
replyMarkup: renderResult.Markup,
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)
{
logger.LogError(ex, "Ошибка при добавлении игрока к сессии");
if (!transactionCommitted)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Произошла ошибка при регистрации.", cancellationToken: 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);
}
}
}
@@ -5,14 +5,17 @@ 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> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0;
ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0;
}
internal static class NewSessionCommandParser
@@ -20,14 +23,22 @@ 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))
{
@@ -43,6 +54,23 @@ internal static class NewSessionCommandParser
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;
@@ -64,6 +92,13 @@ internal static class NewSessionCommandParser
scheduledTimes.Add(scheduledAt);
}
return new NewSessionParseResult(title, link, scheduledTimes, pastTimeInputs, invalidTimeInputs);
return new NewSessionParseResult(
title,
link,
maxPlayers,
scheduledTimes,
pastTimeInputs,
invalidTimeInputs,
invalidSeatLimitInputs);
}
}
@@ -0,0 +1,190 @@
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, bool CanManage, 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,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
FOR UPDATE
""",
new { command.SessionId, command.TelegramUserId },
transaction);
if (session is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
return;
}
if (!session.CanManage)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-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);
}
}
}
@@ -12,7 +12,7 @@ public sealed record DeleteSessionCommand(
long ChatId,
int MessageId);
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId);
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, bool CanManage, int? ThreadId);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource,
@@ -24,13 +24,23 @@ public sealed class DeleteSessionHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Fetch session and verify GM
// 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
@"SELECT s.title as Title, s.batch_id as BatchId, s.thread_id as ThreadId, g.gm_telegram_id as GmId
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.thread_id AS ThreadId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId",
new { command.SessionId }, transaction);
WHERE s.id = @SessionId
""",
new { command.SessionId, command.TelegramUserId }, transaction);
if (session == null)
{
@@ -38,9 +48,9 @@ public sealed class DeleteSessionHandler(
return;
}
if (session.GmId != command.TelegramUserId)
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
return;
}
@@ -74,16 +84,30 @@ public sealed class DeleteSessionHandler(
// A simple way is to re-render the list:
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount,
g.gm_telegram_id as GmId
@"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 AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.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()
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, s.group_id
ORDER BY s.scheduled_at ASC",
new { ChatId = command.ChatId, Cancelled = SessionStatus.Cancelled });
new
{
ChatId = command.ChatId,
command.TelegramUserId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList();
@@ -96,11 +120,15 @@ public sealed class DeleteSessionHandler(
var text = "📅 <b>Ближайшие игры:</b>\n\n";
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 keyboard = isGm
var canManage = sessionsList.First().CanManage;
var keyboard = canManage
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null;
@@ -6,7 +6,7 @@ using Telegram.Bot.Types;
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, bool CanManage);
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource,
@@ -17,16 +17,30 @@ public sealed class ListSessionsHandler(
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount,
g.gm_telegram_id as GmId
@"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 AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.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()
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, s.group_id
ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Cancelled = SessionStatus.Cancelled });
new
{
ChatId = message.Chat.Id,
TelegramUserId = message.From?.Id,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList();
@@ -42,11 +56,15 @@ public sealed class ListSessionsHandler(
var text = "📅 <b>Ближайшие игры:</b>\n\n";
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 keyboard = isGm
var canManage = sessionsList.First().CanManage;
var keyboard = canManage
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null;
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -12,20 +13,26 @@ namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId);
Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode);
internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername);
internal sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles text input from the GM who has an AwaitingTime proposal.
/// Parses the new time, creates a voting message, and tags all participants.
/// Parses reschedule options with a voting deadline, creates a voting message,
/// and tags all participants.
/// If no participants are registered, reschedules immediately.
/// </summary>
public sealed class HandleRescheduleTimeInputHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger)
{
/// <summary>
@@ -48,13 +55,21 @@ public sealed class HandleRescheduleTimeInputHandler(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId
g.telegram_chat_id AS TelegramChatId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.proposed_by = @GmId
AND rp.status = 'AwaitingTime'
AND g.telegram_chat_id = @ChatId
AND EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @GmId
)
ORDER BY rp.created_at DESC
LIMIT 1
""",
@@ -63,68 +78,85 @@ public sealed class HandleRescheduleTimeInputHandler(
if (proposal is null)
return false;
// 2. Parse the new time
if (!MoscowTime.TryParseMoscow(text, out var newTime))
// 2. Parse voting input
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{
await bot.SendMessage(
chatId: chatId,
text: "⚠️ Не удалось распознать время. Используйте формат: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\nНапример: <code>25.04.2026 19:30</code>",
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
return true;
}
if (newTime <= DateTimeOffset.UtcNow)
{
await bot.SendMessage(
chatId: chatId,
text: "⚠️ Новое время должно быть в будущем. Попробуйте снова.",
cancellationToken: ct);
return true;
}
// 3. Load participants (non-GM) signed up for this session
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
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,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
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
if (participants.Count == 0)
{
await RescheduleImmediately(connection, proposal, newTime, chatId, ct);
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
await TryDeleteMessage(chatId, message.MessageId, ct);
return true;
}
// 5. Create voting message
await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
// Update proposal with proposed time and Voting status
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET proposed_at = @ProposedAt, status = 'Voting', vote_chat_id = @ChatId
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
WHERE id = @Id
""",
new { ProposedAt = newTime, ChatId = chatId, Id = proposal.Id },
new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
transaction);
foreach (var option in options)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new
{
option.OptionId,
ProposalId = proposal.Id,
option.ProposedAt,
option.DisplayOrder
},
transaction);
}
await transaction.CommitAsync(ct);
// Build voting message text
var voteText = BuildVotingMessage(proposal.Title, proposal.CurrentScheduledAt, newTime, participants, []);
var keyboard = new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{proposal.Id}"),
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{proposal.Id}")
]
]);
var voteText = BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
votingInput.Deadline,
options,
participants,
[]);
var keyboard = BuildVotingKeyboard(options);
var voteMsg = await bot.SendMessage(
chatId: chatId,
@@ -133,12 +165,46 @@ public sealed class HandleRescheduleTimeInputHandler(
replyMarkup: keyboard,
cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var optionsText = string.Join(
"\n",
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
var directText = $"""
🔄 <b>Голосование за перенос сессии</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
🗳 Варианты:
{optionsText}
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении.
""";
await directSender.SendAsync(
participants.Select(p => new DirectNotificationRecipient(
p.TelegramId,
p.DisplayName)),
directText,
"reschedule-vote",
proposal.SessionId,
ct);
}
// Store vote message ID
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", proposal.SessionId, proposal.Id);
logger.LogInformation(
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
proposal.SessionId,
proposal.Id,
options.Count,
votingInput.Deadline);
// Delete GM's time input message
await TryDeleteMessage(chatId, message.MessageId, ct);
@@ -154,7 +220,11 @@ public sealed class HandleRescheduleTimeInputHandler(
await connection.ExecuteAsync(
"""
UPDATE sessions SET scheduled_at = @NewTime, status = @Status, updated_at = now()
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
@@ -180,33 +250,105 @@ public sealed class HandleRescheduleTimeInputHandler(
}
internal static string BuildVotingMessage(
string title, DateTime currentTime, DateTimeOffset newTime,
string title,
DateTime currentTime,
DateTimeOffset deadline,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyCollection<Guid> approvedPlayerIds)
IReadOnlyList<RescheduleOptionVoteDto> votes)
{
var votesByOption = votes
.GroupBy(v => v.OptionId)
.ToDictionary(g => g.Key, g => g.ToList());
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
var pendingParticipants = participants
.Where(p => !votedPlayerIds.Contains(p.PlayerId))
.Select(FormatParticipantName)
.ToList();
var lines = new List<string>
{
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
"",
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
$"📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)",
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
"",
"Для переноса нужно согласие всех участников:"
"Выберите один из вариантов:"
};
foreach (var p in participants)
foreach (var option in options.OrderBy(x => x.DisplayOrder))
{
var name = p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳";
lines.Add($" {icon} {name}");
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
lines.Add(
$"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК) — {FormatVoteCount(optionVotes.Count)}");
if (optionVotes.Count > 0)
{
lines.Add($" {string.Join(", ", optionVotes.Select(FormatParticipantName))}");
}
}
if (pendingParticipants.Count > 0)
{
lines.Add("");
lines.Add($"Не проголосовали: {string.Join(", ", pendingParticipants)}");
}
lines.Add("");
lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count}");
lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
return string.Join("\n", lines);
}
internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList<RescheduleOptionDto> options)
{
return new InlineKeyboardMarkup(
options
.OrderBy(option => option.DisplayOrder)
.Select(option => new[]
{
InlineKeyboardButton.WithCallbackData(
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
$"reschedule_vote:{option.OptionId}")
}));
}
internal static string FormatParticipantName(VoteParticipantDto participant)
{
return participant.TelegramUsername is { Length: > 0 } username
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
: System.Net.WebUtility.HtmlEncode(participant.DisplayName);
}
internal static string FormatParticipantName(RescheduleOptionVoteDto vote)
{
return vote.TelegramUsername is { Length: > 0 } username
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
: System.Net.WebUtility.HtmlEncode(vote.DisplayName);
}
private static string FormatVoteCount(int count)
{
var modulo100 = count % 100;
var modulo10 = count % 10;
var word = modulo100 is >= 11 and <= 14
? "голосов"
: modulo10 switch
{
1 => "голос",
>= 2 and <= 4 => "голоса",
_ => "голосов"
};
return $"{count} {word}";
}
private static string FormatButtonTime(DateTimeOffset utc)
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString(
"dd.MM HH:mm",
System.Globalization.CultureInfo.InvariantCulture);
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
{
try
@@ -214,17 +356,20 @@ public sealed class HandleRescheduleTimeInputHandler(
await using var conn = await dataSource.OpenConnectionAsync(ct);
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();
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
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.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();
@@ -1,15 +1,12 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleVoteCommand(
Guid ProposalId,
string Vote,
Guid OptionId,
long TelegramUserId,
string CallbackQueryId,
long ChatId,
@@ -18,14 +15,9 @@ public sealed record HandleRescheduleVoteCommand(
internal sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
DateTime ProposedAt,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
string SessionStatus,
long TelegramChatId,
int? ConfirmationMessageId,
int? BatchMessageId);
DateTime CurrentScheduledAt);
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource,
@@ -41,20 +33,15 @@ public sealed class HandleRescheduleVoteHandler(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.proposed_at AS ProposedAt,
rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
s.status AS SessionStatus,
s.confirmation_message_id AS ConfirmationMessageId,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId
FROM reschedule_proposals rp
s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.id = @ProposalId AND rp.status = 'Voting'
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { command.ProposalId },
new { command.OptionId },
transaction);
if (proposal is null)
@@ -66,6 +53,16 @@ public sealed class HandleRescheduleVoteHandler(
return;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Дедлайн уже прошёл. Результаты скоро будут применены.",
showAlert: true,
cancellationToken: ct);
return;
}
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
@@ -74,8 +71,9 @@ public sealed class HandleRescheduleVoteHandler(
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, command.TelegramUserId },
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
@@ -89,150 +87,72 @@ public sealed class HandleRescheduleVoteHandler(
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_votes (proposal_id, player_id, vote)
VALUES (@ProposalId, @PlayerId, @Vote)
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET vote = EXCLUDED.vote,
SET option_id = EXCLUDED.option_id,
voted_at = now()
""",
new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction);
var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
? new List<VoteParticipantDto>()
: (await connection.QueryAsync<VoteParticipantDto>(
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId },
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
? new HashSet<Guid>()
: (await connection.QueryAsync<Guid>(
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT player_id
FROM reschedule_votes
WHERE proposal_id = @ProposalId AND vote = 'yes'
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { command.ProposalId },
transaction)).ToHashSet();
new { ProposalId = proposal.Id },
transaction)).ToList();
var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count);
if (decision.Outcome == RescheduleVoteOutcome.Rejected)
{
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
new { Id = command.ProposalId },
transaction);
await transaction.CommitAsync(ct);
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId });
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: $"❌ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!</b>\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message after rejection");
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
return;
}
if (decision.ShouldRescheduleSession)
{
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
await connection.ExecuteAsync(
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
link_message_id = NULL,
updated_at = now()
WHERE id = @SessionId
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
new { ProposalId = proposal.Id },
transaction)).ToList();
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Approved' WHERE id = @Id",
new { Id = command.ProposalId },
transaction);
if (decision.ShouldResetParticipantRsvps)
{
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId AND is_gm = false
""",
new { proposal.SessionId },
transaction);
}
await transaction.CommitAsync(ct);
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: $"✅ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!</b>\n\nВсе участники согласились.\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)\n\n<i>Уведомления будут приходить согласно новому расписанию.</i>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message after approval");
}
await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation(
"Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
proposal.SessionId,
newTime,
command.ProposalId);
}
else
{
await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
proposal.VotingDeadlineAt,
options,
participants,
approvedPlayerIds);
var keyboard = new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{command.ProposalId}"),
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{command.ProposalId}")
]
]);
votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
try
{
@@ -246,60 +166,12 @@ public sealed class HandleRescheduleVoteHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message with progress");
}
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
}
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
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",
new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
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.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
{
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Ваш голос учтён. До дедлайна его можно изменить.",
cancellationToken: ct);
}
else
{
await bot.SendMessage(
chatId: proposal.TelegramChatId,
text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id);
}
}
}
@@ -16,14 +16,14 @@ public sealed record InitiateRescheduleCommand(
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RescheduleSessionInfoDto(string Title, long GmId);
internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles the "⏰ Перенести" button press from the batch message.
/// Creates a reschedule proposal in AwaitingTime status and prompts
/// the GM to enter the new time via a regular text message.
/// the GM to enter 2-3 new time options and a voting deadline.
/// </summary>
public sealed class InitiateRescheduleHandler(
NpgsqlDataSource dataSource,
@@ -34,15 +34,21 @@ public sealed class InitiateRescheduleHandler(
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Verify GM ownership
// 1. Verify group management access.
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
"""
SELECT s.title AS Title, g.gm_telegram_id AS GmId
SELECT s.title AS Title,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId AND s.status != @Cancelled
""",
new { command.SessionId, Cancelled = SessionStatus.Cancelled });
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
if (session is null)
{
@@ -50,10 +56,10 @@ public sealed class InitiateRescheduleHandler(
return;
}
if (session.GmId != command.TelegramUserId)
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
"Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct);
return;
}
@@ -86,11 +92,20 @@ public sealed class InitiateRescheduleHandler(
// 4. Prompt GM in chat
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Введите новое время в чат (формат: ДД.ММ.ГГГГ ЧЧ:ММ)", cancellationToken: ct);
"Введите 2-3 варианта времени и дедлайн голосования.", cancellationToken: ct);
await bot.SendMessage(
chatId: command.ChatId,
text: $"⏰ Укажите новое время для сессии «{session.Title}» в формате:\n<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\nНапример: <code>25.04.2026 19:30</code>",
text: $"""
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
Формат:
<code>25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00</code>
Дедлайн должен быть в будущем и раньше первого предложенного времени.
""",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
@@ -9,27 +9,57 @@ internal enum RescheduleVoteOutcome
internal sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome,
string CallbackText,
bool ShouldRescheduleSession,
bool ShouldResetParticipantRsvps);
string Reason,
Guid? SelectedOptionId = null,
string CallbackText = "",
bool ShouldRescheduleSession = false,
bool ShouldResetParticipantRsvps = false);
internal static class RescheduleVoteRules
{
public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts)
{
var maxVotes = voteCounts.Count == 0 ? 0 : voteCounts.Max(x => x.VoteCount);
if (maxVotes == 0)
{
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Rejected,
"Никто не проголосовал до дедлайна, перенос не применяется.");
}
var winners = voteCounts.Where(x => x.VoteCount == maxVotes).ToList();
if (winners.Count > 1)
{
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Rejected,
"Голоса разделились поровну, перенос не применяется.");
}
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Approved,
"Победил вариант с большинством голосов.",
winners[0].OptionId,
ShouldRescheduleSession: true,
ShouldResetParticipantRsvps: true);
}
public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants)
{
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
{
return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected,
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.",
ShouldRescheduleSession: false,
ShouldResetParticipantRsvps: false);
Reason: "\u041e\u0434\u0438\u043d \u0438\u0437 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432 \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b \u043f\u0435\u0440\u0435\u043d\u043e\u0441.",
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.");
}
var everyoneApproved = approvedParticipants == totalParticipants;
return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
Reason: everyoneApproved
? "\u0412\u0441\u0435 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b."
: "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0435\u0442\u0441\u044f.",
CallbackText: everyoneApproved
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
@@ -0,0 +1,361 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record DueRescheduleProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
int? VoteMessageId,
long TelegramChatId,
string NotificationMode);
public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await ProcessDueProposals(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ProcessDueProposals(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
private async Task ProcessDueProposals(CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var proposalIds = (await connection.QueryAsync<Guid>(
"""
SELECT id
FROM reschedule_proposals
WHERE status = 'Voting'
AND voting_deadline_at IS NOT NULL
AND voting_deadline_at <= now()
ORDER BY voting_deadline_at
LIMIT 25
""")).ToList();
foreach (var proposalId in proposalIds)
{
await FinalizeProposal(proposalId, ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process due reschedule voting proposals");
}
}
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
rp.vote_message_id AS VoteMessageId,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
s.batch_message_id AS BatchMessageId,
s.notification_mode AS NotificationMode,
g.telegram_chat_id AS TelegramChatId
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.id = @ProposalId
AND rp.status = 'Voting'
AND rp.voting_deadline_at IS NOT NULL
AND rp.voting_deadline_at <= now()
FOR UPDATE
""",
new { ProposalId = proposalId },
transaction);
if (proposal is null)
return;
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var voteCounts = options
.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId)))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(voteCounts);
var selectedOption = decision.SelectedOptionId is { } selectedOptionId
? options.Single(x => x.OptionId == selectedOptionId)
: null;
if (selectedOption is not null)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
link_message_id = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET status = 'Approved',
selected_option_id = @SelectedOptionId,
proposed_at = @ProposedAt
WHERE id = @ProposalId
""",
new
{
ProposalId = proposal.Id,
SelectedOptionId = selectedOption.OptionId,
ProposedAt = selectedOption.ProposedAt
},
transaction);
}
else
{
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId",
new { ProposalId = proposal.Id },
transaction);
}
var directRecipients = participants
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
.ToList();
await transaction.CommitAsync(ct);
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct);
if (selectedOption is not null)
{
await TryUpdateBatchMessage(proposal, ct);
}
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct);
}
logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposal.Id,
proposal.SessionId,
decision.Outcome);
}
private async Task TryUpdateVoteMessage(
DueRescheduleProposalDto proposal,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
CancellationToken ct)
{
if (proposal.VoteMessageId is null)
return;
try
{
var resultText = selectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {selectedOption.DisplayOrder}: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}";
var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
options,
participants,
votes)}
{resultText}
""";
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.VoteMessageId.Value,
text: text,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id);
}
}
private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await connection.QueryAsync<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 { proposal.BatchId })).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 { proposal.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
{
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
parseMode: ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
else
{
await bot.SendMessage(
chatId: proposal.TelegramChatId,
text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
parseMode: ParseMode.Html,
cancellationToken: ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id);
}
}
private async Task SendDirectResult(
DueRescheduleProposalDto proposal,
IReadOnlyList<DirectNotificationRecipient> recipients,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
CancellationToken ct)
{
var htmlText = selectedOption is not null
? $"""
✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Новое время: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
"""
: $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)}
""";
await directSender.SendAsync(
recipients,
htmlText,
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
proposal.SessionId,
ct);
}
}
@@ -0,0 +1,110 @@
using System.Text.RegularExpressions;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record RescheduleVotingInput(
IReadOnlyList<DateTimeOffset> Options,
DateTimeOffset Deadline)
{
private static readonly Regex DateTimePattern = new(
@"(?<date>\d{1,2}\.\d{2}\.\d{4})\s+(?<time>\d{1,2}:\d{2})",
RegexOptions.CultureInvariant);
public static bool TryParse(
string text,
DateTimeOffset nowUtc,
out RescheduleVotingInput input,
out string error)
{
input = new RescheduleVotingInput([], default);
error = string.Empty;
var options = new List<DateTimeOffset>();
DateTimeOffset? deadline = null;
foreach (var rawLine in text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var line = rawLine.Trim();
var match = DateTimePattern.Match(line);
if (!match.Success)
continue;
var value = $"{match.Groups["date"].Value} {match.Groups["time"].Value}";
if (!MoscowTime.TryParseMoscow(value, out var parsed))
continue;
if (IsDeadlineLine(line))
{
deadline = parsed;
}
else
{
options.Add(parsed);
}
}
if (options.Count is < 2 or > 3)
{
error = "Укажите от 2 до 3 вариантов времени.";
return false;
}
if (options.Distinct().Count() != options.Count)
{
error = "Варианты времени не должны повторяться.";
return false;
}
if (deadline is null)
{
error = "Укажите дедлайн голосования строкой «Дедлайн: ДД.ММ.ГГГГ ЧЧ:ММ».";
return false;
}
if (options.Any(option => option <= nowUtc))
{
error = "Все варианты времени должны быть в будущем.";
return false;
}
if (deadline.Value <= nowUtc)
{
error = "Дедлайн голосования должен быть в будущем.";
return false;
}
if (deadline.Value >= options.Min())
{
error = "Дедлайн голосования должен быть раньше первого предложенного времени.";
return false;
}
input = new RescheduleVotingInput(options, deadline.Value);
return true;
}
private static bool IsDeadlineLine(string line)
{
var normalized = line.TrimStart('-', '*', ' ', '\t').ToLowerInvariant();
return normalized.StartsWith("дедлайн", StringComparison.Ordinal)
|| normalized.StartsWith("deadline", StringComparison.Ordinal)
|| normalized.StartsWith("до:", StringComparison.Ordinal);
}
}
internal sealed record RescheduleOptionDto(
Guid OptionId,
int DisplayOrder,
DateTimeOffset ProposedAt);
internal sealed record RescheduleOptionVoteDto(
Guid OptionId,
Guid PlayerId,
string DisplayName,
string? TelegramUsername);
internal sealed record RescheduleOptionVoteCount(
Guid OptionId,
int VoteCount);
@@ -2,6 +2,7 @@ using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Scheduling;
@@ -17,11 +18,13 @@ namespace GmRelay.Bot.Infrastructure.Scheduling;
public sealed class SessionSchedulerService(
NpgsqlDataSource dataSource,
SendConfirmationHandler confirmationHandler,
SendOneHourReminderHandler oneHourReminderHandler,
SendJoinLinkHandler joinLinkHandler,
ILogger<SessionSchedulerService> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -36,6 +39,7 @@ public sealed class SessionSchedulerService(
try
{
await ProcessConfirmationTriggers(stoppingToken);
await ProcessOneHourReminderTriggers(stoppingToken);
await ProcessJoinLinkTriggers(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
@@ -52,6 +56,42 @@ public sealed class SessionSchedulerService(
logger.LogInformation("Session scheduler stopped");
}
/// <summary>
/// T-1h trigger: process direct reminders according to the session notification mode.
/// </summary>
private async Task ProcessOneHourReminderTriggers(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var sessionIds = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status IN (@Confirmed, @ConfirmationSent)
AND scheduled_at - @LeadTime <= now()
AND one_hour_reminder_processed_at IS NULL
""",
new
{
Confirmed = SessionStatus.Confirmed,
ConfirmationSent = SessionStatus.ConfirmationSent,
LeadTime = OneHourReminderLeadTime
});
foreach (var sessionId in sessionIds)
{
try
{
await oneHourReminderHandler.HandleAsync(sessionId, ct);
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
}
}
}
/// <summary>
/// T-24h trigger: find sessions that need confirmation requests sent.
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
@@ -20,6 +20,8 @@ public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
CancelSessionHandler cancelSessionHandler,
DeleteSessionHandler deleteSessionHandler,
ListSessionsHandler listSessionsHandler,
@@ -72,6 +74,19 @@ public sealed class UpdateRouter(
return;
}
if (action == "leave_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var leaveSessionId))
{
var command = new LeaveSessionCommand(
SessionId: leaveSessionId,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await leaveSessionHandler.HandleAsync(command, ct);
return;
}
if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId))
{
var command = new CancelSessionCommand(
@@ -85,6 +100,19 @@ public sealed class UpdateRouter(
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))
{
var command = new DeleteSessionCommand(
@@ -111,15 +139,10 @@ public sealed class UpdateRouter(
return;
}
if (action == "reschedule_vote" && parts.Length >= 3 && Guid.TryParse(parts[2], out var proposalId))
if (action == "reschedule_vote" && parts.Length >= 2 && Guid.TryParse(parts[1], out var optionId))
{
var vote = parts[1]; // "yes" or "no"
if (vote is not ("yes" or "no"))
return;
var command = new HandleRescheduleVoteCommand(
ProposalId: proposalId,
Vote: vote,
OptionId: optionId,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
@@ -192,9 +215,12 @@ public sealed class UpdateRouter(
/newsession
Название: My Game
Время: 15.05.2026 19:30
Мест: 4
Ссылка: https://link
/listsessions список предстоящих сессий
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования.
/help эта справка
""",
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';
@@ -0,0 +1,8 @@
ALTER TABLE sessions
ADD COLUMN notification_mode VARCHAR(50) NOT NULL DEFAULT 'GroupAndDirect'
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
ADD COLUMN one_hour_reminder_processed_at TIMESTAMPTZ;
CREATE INDEX ix_sessions_one_hour_reminders ON sessions (scheduled_at)
WHERE status IN ('Confirmed', 'ConfirmationSent')
AND one_hour_reminder_processed_at IS NULL;
@@ -0,0 +1,26 @@
-- Add explicit owner/co-GM management roles for each Telegram group.
INSERT INTO players (telegram_id, display_name)
SELECT DISTINCT gg.gm_telegram_id,
'GM ' || gg.gm_telegram_id::text
FROM game_groups gg
ON CONFLICT (telegram_id) DO NOTHING;
CREATE TABLE group_managers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL CHECK (role IN ('Owner', 'CoGm')),
added_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (group_id, player_id)
);
INSERT INTO group_managers (group_id, player_id, role)
SELECT gg.id, p.id, 'Owner'
FROM game_groups gg
JOIN players p ON p.telegram_id = gg.gm_telegram_id
ON CONFLICT (group_id, player_id) DO NOTHING;
CREATE INDEX ix_group_managers_group_role ON group_managers (group_id, role);
CREATE INDEX ix_group_managers_player ON group_managers (player_id);
@@ -0,0 +1,35 @@
-- Multi-option reschedule voting with a deadline.
ALTER TABLE reschedule_proposals
ADD COLUMN voting_deadline_at TIMESTAMPTZ,
ADD COLUMN selected_option_id UUID;
CREATE TABLE reschedule_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
proposed_at TIMESTAMPTZ NOT NULL,
display_order INTEGER NOT NULL CHECK (display_order BETWEEN 1 AND 3),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (proposal_id, id),
UNIQUE (proposal_id, display_order),
UNIQUE (proposal_id, proposed_at)
);
CREATE TABLE reschedule_option_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
option_id UUID NOT NULL,
voted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (proposal_id, player_id),
FOREIGN KEY (proposal_id, option_id)
REFERENCES reschedule_options(proposal_id, id)
ON DELETE CASCADE
);
CREATE INDEX ix_reschedule_voting_deadline
ON reschedule_proposals (voting_deadline_at)
WHERE status = 'Voting';
CREATE INDEX ix_reschedule_option_votes_option
ON reschedule_option_votes (option_id);
+7
View File
@@ -1,6 +1,8 @@
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database;
@@ -50,10 +52,14 @@ builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
// ── Feature handlers (explicit registration — AOT safe) ──────────────
builder.Services.AddSingleton<SendConfirmationHandler>();
builder.Services.AddSingleton<DirectSessionNotificationSender>();
builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
@@ -69,6 +75,7 @@ builder.Services.AddHostedService<TelegramBotService>();
// ── Session scheduler ────────────────────────────────────────────────
builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
var host = builder.Build();
@@ -12,11 +12,11 @@
<PackageReference Include="Microsoft.Extensions.Http.Resilience" 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.Extensions.Hosting" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,34 @@
namespace GmRelay.Shared.Domain;
public enum GroupManagerRole
{
Owner,
CoGm
}
public static class GroupManagerRoleExtensions
{
public const string OwnerValue = "Owner";
public const string CoGmValue = "CoGm";
public static string ToDatabaseValue(this GroupManagerRole role) => role switch
{
GroupManagerRole.Owner => OwnerValue,
GroupManagerRole.CoGm => CoGmValue,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.")
};
public static GroupManagerRole FromDatabaseValue(string value) => value switch
{
OwnerValue => GroupManagerRole.Owner,
CoGmValue => GroupManagerRole.CoGm,
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown group manager role.")
};
public static string ToDisplayName(this GroupManagerRole role) => role switch
{
GroupManagerRole.Owner => "Owner",
GroupManagerRole.CoGm => "Co-GM",
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.")
};
}
@@ -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);
}
}
@@ -0,0 +1,33 @@
namespace GmRelay.Shared.Domain;
public enum SessionNotificationMode
{
GroupAndDirect,
GroupOnly
}
public static class SessionNotificationModeExtensions
{
public const string GroupAndDirectValue = nameof(SessionNotificationMode.GroupAndDirect);
public const string GroupOnlyValue = nameof(SessionNotificationMode.GroupOnly);
public static bool ShouldSendDirectMessages(this SessionNotificationMode mode) =>
mode == SessionNotificationMode.GroupAndDirect;
public static string ToDatabaseValue(this SessionNotificationMode mode) =>
mode switch
{
SessionNotificationMode.GroupAndDirect => GroupAndDirectValue,
SessionNotificationMode.GroupOnly => GroupOnlyValue,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown notification mode.")
};
public static SessionNotificationMode FromDatabaseValue(string? value) =>
value switch
{
null or "" => SessionNotificationMode.GroupAndDirect,
GroupAndDirectValue => SessionNotificationMode.GroupAndDirect,
GroupOnlyValue => SessionNotificationMode.GroupOnly,
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown notification mode.")
};
}
@@ -3,8 +3,8 @@ using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername);
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
public static class SessionBatchRenderer
{
@@ -22,10 +22,17 @@ public static class SessionBatchRenderer
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 += $"👥 Игроки ({sessionPlayers.Count}):\n";
messageText += session.MaxPlayers.HasValue
? $"👥 Места: {sessionPlayers.Count}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({sessionPlayers.Count}):\n";
if (sessionPlayers.Count > 0)
{
@@ -36,6 +43,12 @@ public static class SessionBatchRenderer
messageText += " <i>Пока никто не записался</i>\n";
}
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";
@@ -46,13 +59,32 @@ public static class SessionBatchRenderer
var dateTitle = session.ScheduledAt.FormatMoscowShort();
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($"⏰ (ГМ)", $"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));
}
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>
</form>
<div class="nav-version">v1.1.0</div>
<div class="nav-version">v1.3.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -51,6 +51,12 @@
<InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
</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;">
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
@@ -97,6 +103,7 @@
model.Title = session.Title;
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink;
model.MaxPlayers = session.MaxPlayers;
}
private async Task HandleSubmit()
@@ -115,7 +122,7 @@
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}");
}
catch (SessionAccessDeniedException)
@@ -139,5 +146,6 @@
public string Title { get; set; } = "";
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
public string JoinLink { get; set; } = "";
public int? MaxPlayers { get; set; }
}
}
@@ -20,6 +20,71 @@
<h2>📅 Предстоящие игры</h2>
</div>
@if (groupManagement is not null)
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Управление группой</h3>
<p>@groupManagement.Group.Name · @FormatRole(CurrentUserRole)</p>
</div>
<span class="status-badge status-info">@FormatRole(CurrentUserRole)</span>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
@foreach (var manager in groupManagement.Managers)
{
<span class="status-badge @(manager.Role == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatManager(manager)
</span>
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
{
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать")
</button>
}
}
</div>
@if (groupManagement.CurrentUserIsOwner)
{
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Telegram ID co-GM</label>
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Имя</label>
<InputText @bind-Value="coGmModel.DisplayName" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Username</label>
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
@(isAddingCoGm ? "⏳ Добавляем..." : " Добавить co-GM")
</button>
</EditForm>
}
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
✅ @successMessage
</div>
}
@if (sessions == null)
{
<div class="glass-card" style="padding: 2rem;">
@@ -41,6 +106,72 @@
}
else
{
<div class="batch-bulk-grid animate-slide-up">
@foreach (var batch in batchModels)
{
<div class="batch-bulk-card">
<div class="batch-bulk-header">
<div>
<h3>@batch.Title</h3>
<p>@FormatBatchSummary(batch)</p>
</div>
<span class="status-badge status-info">Batch</span>
</div>
<EditForm Model="@batch" OnValidSubmit="@(() => UpdateBatchDetails(batch))">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Общее название</label>
<InputText @bind-Value="batch.Title" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Общая ссылка</label>
<InputText @bind-Value="batch.JoinLink" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Уведомления игрокам</label>
<select @bind="batch.NotificationMode" class="gm-form-control">
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
</select>
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@IsBatchBusy(batch)">
@(IsBatchBusy(batch) ? "⏳ Обновляем..." : "💾 Обновить batch")
</button>
</EditForm>
<div class="batch-bulk-divider"></div>
<EditForm Model="@batch" OnValidSubmit="@(() => RescheduleBatch(batch))">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Первая дата пачки (МСК, UTC+3)</label>
<input type="datetime-local" @bind="batch.FirstScheduledAtLocal" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Шаг между играми, дней</label>
<InputNumber @bind-Value="batch.IntervalDays" class="gm-form-control" min="1" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-outline" disabled="@IsBatchBusy(batch)">
@(IsBatchBusy(batch) ? "⏳ Переносим..." : "📅 Перенести пачку")
</button>
</EditForm>
<div class="batch-clone-row">
<select @bind="batch.CloneInterval" class="gm-form-control">
<option value="week">Следующая неделя</option>
<option value="month">Следующий месяц</option>
</select>
<button type="button" class="btn-gm btn-gm-success" disabled="@IsBatchBusy(batch)" @onclick="() => CloneBatch(batch)">
@(IsBatchBusy(batch) ? "⏳ Клонируем..." : "🧬 Клонировать")
</button>
</div>
</div>
}
</div>
@* Desktop table *@
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
<table class="gm-table">
@@ -48,6 +179,7 @@
<tr>
<th>Название</th>
<th>Время (МСК)</th>
<th>Места</th>
<th>Статус</th>
<th>Ссылка</th>
<th>Действие</th>
@@ -59,6 +191,7 @@
<tr>
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
<td>@session.ScheduledAt.FormatMoscow()</td>
<td>@FormatSeats(session)</td>
<td>
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</td>
@@ -69,9 +202,17 @@
</a>
</td>
<td>
<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>
@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>
</tr>
}
@@ -93,6 +234,10 @@
<span>🕐 Время</span>
<span style="color: var(--text-primary);">@session.ScheduledAt.FormatMoscow()</span>
</div>
<div class="session-card-row">
<span>👥 Места</span>
<span style="color: var(--text-primary);">@FormatSeats(session)</span>
</div>
<div class="session-card-row">
<span>🔗 Ссылка</span>
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer">Подключиться ↗</a>
@@ -102,6 +247,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>
@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>
}
@@ -112,11 +263,33 @@
@code {
[Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions;
private WebGroupManagement? groupManagement;
private List<BatchBulkEditModel> batchModels = [];
private Guid? promotingSessionId;
private Guid? processingBatchId;
private long? removingCoGmId;
private bool isAddingCoGm;
private long telegramId;
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
if (!authState.User.TryGetTelegramId(out telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
await LoadSessions();
}
private async Task LoadSessions()
{
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
@@ -126,9 +299,287 @@
if (sessions is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
RebuildBatchModels();
}
private async Task AddCoGm()
{
errorMessage = null;
successMessage = null;
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
{
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
return;
}
isAddingCoGm = true;
try
{
await SessionService.AddCoGmForOwnerAsync(
GroupId,
telegramId,
coGmModel.TelegramId.Value,
coGmModel.DisplayName,
coGmModel.TelegramUsername);
coGmModel = new();
successMessage = "Co-GM добавлен.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось добавить co-GM: " + ex.Message;
}
finally
{
isAddingCoGm = false;
}
}
private async Task RemoveCoGm(long coGmTelegramId)
{
errorMessage = null;
successMessage = null;
removingCoGmId = coGmTelegramId;
try
{
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
successMessage = "Co-GM удалён.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить co-GM: " + ex.Message;
}
finally
{
removingCoGmId = null;
}
}
private async Task PromoteWaitlisted(Guid sessionId)
{
errorMessage = null;
successMessage = null;
promotingSessionId = sessionId;
try
{
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
promotingSessionId = null;
}
}
private async Task UpdateBatchDetails(BatchBulkEditModel batch)
{
errorMessage = null;
successMessage = null;
if (!ValidateBatchDetails(batch))
{
errorMessage = "Название и ссылка для batch не должны быть пустыми.";
return;
}
processingBatchId = batch.BatchId;
try
{
await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink);
await SessionService.UpdateBatchNotificationModeForGmAsync(
batch.BatchId,
telegramId,
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
successMessage = "Настройки batch обновлены.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить пачку: " + ex.Message;
}
finally
{
processingBatchId = null;
}
}
private async Task RescheduleBatch(BatchBulkEditModel batch)
{
errorMessage = null;
successMessage = null;
if (batch.IntervalDays <= 0)
{
errorMessage = "Шаг между играми должен быть больше 0 дней.";
return;
}
processingBatchId = batch.BatchId;
try
{
var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays);
successMessage = "Расписание пачки обновлено.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось перенести пачку: " + ex.Message;
}
finally
{
processingBatchId = null;
}
}
private async Task CloneBatch(BatchBulkEditModel batch)
{
errorMessage = null;
successMessage = null;
processingBatchId = batch.BatchId;
try
{
var interval = batch.CloneInterval == "month"
? BatchCloneInterval.NextMonth
: BatchCloneInterval.NextWeek;
var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval);
successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось клонировать пачку: " + ex.Message;
}
finally
{
processingBatchId = null;
}
}
private void RebuildBatchModels()
{
batchModels = sessions?
.GroupBy(session => session.BatchId)
.Select(group =>
{
var orderedSessions = group.OrderBy(session => session.ScheduledAt).ToList();
var firstSession = orderedSessions[0];
var lastSession = orderedSessions[^1];
return new BatchBulkEditModel
{
BatchId = group.Key,
Title = firstSession.Title,
JoinLink = firstSession.JoinLink,
NotificationMode = firstSession.NotificationMode,
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
IntervalDays = InferIntervalDays(orderedSessions),
SessionCount = orderedSessions.Count
};
})
.OrderBy(batch => batch.FirstScheduledAtLocal)
.ToList() ?? [];
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{
batch.Title = batch.Title.Trim();
batch.JoinLink = batch.JoinLink.Trim();
return batch.Title.Length > 0 && batch.JoinLink.Length > 0;
}
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatManager(WebGroupManager manager)
{
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: "@" + manager.TelegramUsername;
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
}
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
{
if (orderedSessions.Count < 2)
{
return 7;
}
var days = (orderedSessions[1].ScheduledAt - orderedSessions[0].ScheduledAt).TotalDays;
return Math.Max(1, (int)Math.Round(days, MidpointRounding.AwayFromZero));
}
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 static string FormatBatchSummary(BatchBulkEditModel batch) =>
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
private string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
@@ -146,4 +597,24 @@
SessionStatus.Cancelled => "Отменено",
_ => status
};
private sealed class BatchBulkEditModel
{
public Guid BatchId { get; init; }
public string Title { get; set; } = "";
public string JoinLink { get; set; } = "";
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
public int IntervalDays { get; set; } = 7;
public int SessionCount { get; init; }
public string CloneInterval { get; set; } = "week";
}
private sealed class CoGmEditModel
{
public long? TelegramId { get; set; }
public string DisplayName { get; set; } = "";
public string? TelegramUsername { get; set; }
}
}
@@ -1,6 +1,7 @@
@page "/"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using GmRelay.Shared.Domain
@using GmRelay.Web.Services
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@@ -43,6 +44,9 @@
<div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @group.TelegramChatId</p>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
@FormatRole(group.ManagerRole)
</span>
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
Посмотреть игры →
</a>
@@ -97,4 +101,7 @@
groups = await SessionService.GetGroupsForGmAsync(telegramId);
}
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
}
@@ -1,3 +1,5 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
@@ -5,6 +7,24 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
sessionStore.GetGroupsForGmAsync(gmId);
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
var group = await sessionStore.GetGroupAsync(groupId);
if (group is null)
{
return null;
}
var managers = await sessionStore.GetGroupManagersAsync(groupId);
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId);
return new WebGroupManagement(group, managers, isOwner);
}
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
@@ -26,7 +46,18 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
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<WebSessionBatch?> GetBatchForGmAsync(Guid batchId, long gmId)
{
var batch = await sessionStore.GetBatchAsync(batchId);
if (batch is null)
{
return null;
}
return await GroupBelongsToGmAsync(batch.GroupId, gmId) ? batch : null;
}
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
@@ -34,12 +65,108 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
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);
}
public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink)
{
var batch = await GetBatchForGmAsync(batchId, gmId);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, gmId);
}
await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim());
}
public async Task UpdateBatchNotificationModeForGmAsync(Guid batchId, long gmId, SessionNotificationMode notificationMode)
{
var batch = await GetBatchForGmAsync(batchId, gmId);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, gmId);
}
await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode);
}
public async Task RescheduleBatchForGmAsync(Guid batchId, long gmId, DateTime firstScheduledAt, int intervalDays)
{
if (intervalDays <= 0)
{
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero.");
}
var batch = await GetBatchForGmAsync(batchId, gmId);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, gmId);
}
await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays);
}
public async Task<WebSessionBatch> CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval)
{
var batch = await GetBatchForGmAsync(batchId, gmId);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, gmId);
}
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
}
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
if (coGmTelegramId <= 0)
{
throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero.");
}
if (ownerTelegramId == coGmTelegramId)
{
throw new InvalidOperationException("Owner is already a group manager.");
}
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
}
var normalizedName = string.IsNullOrWhiteSpace(displayName)
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
: displayName.Trim();
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername)
? null
: telegramUsername.Trim().TrimStart('@');
await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername);
}
public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId)
{
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
}
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
}
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
{
var group = await sessionStore.GetGroupAsync(groupId);
return group?.GmTelegramId == gmId;
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
}
}
@@ -0,0 +1,46 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public enum BatchCloneInterval
{
NextWeek,
NextMonth
}
public sealed record WebSessionBatch(
Guid Id,
Guid GroupId,
string Title,
string JoinLink,
DateTime FirstScheduledAt,
DateTime LastScheduledAt,
int SessionCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
public static class BatchSchedulePlanner
{
public static IReadOnlyList<DateTime> BuildFixedIntervalSchedule(
IEnumerable<DateTime> currentSchedule,
DateTime firstScheduledAt,
int intervalDays)
{
if (intervalDays <= 0)
{
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero.");
}
return currentSchedule
.OrderBy(scheduledAt => scheduledAt)
.Select((_, index) => firstScheduledAt.AddDays(intervalDays * index))
.ToList();
}
public static DateTime ShiftForClone(DateTime scheduledAt, BatchCloneInterval interval) =>
interval switch
{
BatchCloneInterval.NextWeek => scheduledAt.AddDays(7),
BatchCloneInterval.NextMonth => scheduledAt.AddMonths(1),
_ => throw new ArgumentOutOfRangeException(nameof(interval), interval, "Unknown clone interval.")
};
}
+14 -1
View File
@@ -1,10 +1,23 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public interface ISessionStore
{
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId);
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId);
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink);
Task<WebSessionBatch?> GetBatchAsync(Guid batchId);
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers);
Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId);
Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink);
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
}
+749 -16
View File
@@ -6,8 +6,63 @@ using Telegram.Bot;
namespace GmRelay.Web.Services;
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 WebGameGroup(
Guid Id,
long TelegramChatId,
string Name,
long GmTelegramId,
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
public sealed record WebGroupManager(
long TelegramId,
string DisplayName,
string? TelegramUsername,
string Role,
DateTime AddedAt);
public sealed record WebGroupManagement(
WebGameGroup Group,
IReadOnlyList<WebGroupManager> Managers,
bool CurrentUserIsOwner);
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,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName);
internal sealed record WebBatchInfo(
Guid BatchId,
Guid GroupId,
string Title,
string JoinLink,
long TelegramChatId,
int? BatchMessageId,
int? ThreadId,
string NotificationMode);
internal sealed record WebBatchSessionRow(
Guid Id,
Guid GroupId,
string Title,
string JoinLink,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
int? BatchMessageId,
long TelegramChatId,
int? ThreadId,
string NotificationMode);
public sealed class SessionService(
NpgsqlDataSource dataSource,
@@ -18,7 +73,18 @@ public sealed class SessionService(
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE gm_telegram_id = @GmId",
"""
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.name,
g.gm_telegram_id AS GmTelegramId,
gm.role AS ManagerRole
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE p.telegram_id = @GmId
ORDER BY g.name
""",
new { GmId = gmId })).ToList();
}
@@ -26,8 +92,145 @@ public sealed class SessionService(
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId });
"""
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.name,
g.gm_telegram_id AS GmTelegramId,
@OwnerRole AS ManagerRole
FROM game_groups g
WHERE g.id = @GroupId
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
)
""",
new { GroupId = groupId, TelegramId = telegramId });
}
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
AND gm.role = @OwnerRole
)
""",
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGroupManager>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
gm.role AS Role,
gm.created_at AS AddedAt
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
gm.created_at,
p.display_name
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
}
public async Task AddGroupCoGmAsync(
Guid groupId,
long ownerTelegramId,
long coGmTelegramId,
string displayName,
string? telegramUsername)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TelegramId, @DisplayName, @TelegramUsername)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username
""",
new
{
TelegramId = coGmTelegramId,
DisplayName = displayName,
TelegramUsername = telegramUsername
},
transaction);
await conn.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
SELECT @GroupId,
co_gm.id,
@CoGmRole,
owner_player.id
FROM players co_gm
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
WHERE co_gm.telegram_id = @CoGmTelegramId
ON CONFLICT (group_id, player_id) DO UPDATE
SET role = CASE
WHEN group_managers.role = @OwnerRole THEN group_managers.role
ELSE EXCLUDED.role
END,
added_by_player_id = EXCLUDED.added_by_player_id
""",
new
{
GroupId = groupId,
OwnerTelegramId = ownerTelegramId,
CoGmTelegramId = coGmTelegramId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
},
transaction);
await transaction.CommitAsync();
}
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"""
DELETE FROM group_managers gm
USING players p
WHERE gm.player_id = p.id
AND gm.group_id = @GroupId
AND p.telegram_id = @CoGmTelegramId
AND gm.role = @CoGmRole
""",
new
{
GroupId = groupId,
CoGmTelegramId = coGmTelegramId,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
});
}
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
@@ -36,12 +239,35 @@ public sealed class SessionService(
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,
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,
s.notification_mode AS NotificationMode
FROM sessions s
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'
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)
@@ -50,14 +276,57 @@ public sealed class SessionService(
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,
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,
s.notification_mode AS NotificationMode
FROM sessions s
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",
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<WebSessionBatch?> GetBatchAsync(Guid batchId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebSessionBatch>(
"""
SELECT s.batch_id AS Id,
s.group_id AS GroupId,
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
MIN(s.scheduled_at) AS FirstScheduledAt,
MAX(s.scheduled_at) AS LastScheduledAt,
COUNT(*)::int AS SessionCount,
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
FROM sessions s
WHERE s.batch_id = @BatchId
GROUP BY s.batch_id, s.group_id
""",
new { BatchId = batchId });
}
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 transaction = await conn.BeginTransactionAsync();
@@ -65,7 +334,11 @@ public sealed class SessionService(
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,
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,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id AND s.group_id = @GroupId",
@@ -82,9 +355,22 @@ public sealed class SessionService(
SET title = @Title,
scheduled_at = @ScheduledAt,
join_link = @JoinLink,
max_players = @MaxPlayers,
one_hour_reminder_processed_at = CASE
WHEN scheduled_at <> @ScheduledAt THEN NULL
ELSE one_hour_reminder_processed_at
END,
updated_at = now()
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);
if (updatedRows == 0)
@@ -102,16 +388,434 @@ public sealed class SessionService(
var timeChanged = oldSession.ScheduledAt != scheduledAt;
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\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);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var recipients = await LoadSessionDirectRecipientsAsync(conn, sessionId);
await SendDirectNotificationsAsync(recipients, notification, "web-session-updated", sessionId);
}
if (oldSession.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
}
}
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,
s.notification_mode AS NotificationMode
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);
}
}
public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, 0);
}
var updatedRows = await conn.ExecuteAsync(
"""
UPDATE sessions
SET title = @Title,
join_link = @JoinLink,
updated_at = now()
WHERE batch_id = @BatchId
AND group_id = @GroupId
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
JoinLink = joinLink
},
transaction);
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(batchId, 0);
}
await transaction.CommitAsync();
if (batch.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(batchId, batch.TelegramChatId, batch.BatchMessageId.Value, title);
}
}
public async Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, 0);
}
var updatedRows = await conn.ExecuteAsync(
"""
UPDATE sessions
SET notification_mode = @NotificationMode,
updated_at = now()
WHERE batch_id = @BatchId
AND group_id = @GroupId
""",
new
{
BatchId = batchId,
GroupId = groupId,
NotificationMode = notificationMode.ToDatabaseValue()
},
transaction);
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(batchId, 0);
}
await transaction.CommitAsync();
}
public async Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var batchSessions = (await conn.QueryAsync<WebBatchSessionRow>(
"""
SELECT s.id AS Id,
s.group_id AS GroupId,
s.title AS Title,
s.join_link AS JoinLink,
s.scheduled_at AS ScheduledAt,
s.status AS Status,
s.max_players AS MaxPlayers,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.batch_id = @BatchId
AND s.group_id = @GroupId
ORDER BY s.scheduled_at
FOR UPDATE
""",
new { BatchId = batchId, GroupId = groupId },
transaction)).ToList();
if (batchSessions.Count == 0)
{
throw new SessionAccessDeniedException(batchId, 0);
}
var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule(
batchSessions.Select(session => session.ScheduledAt),
firstScheduledAt,
intervalDays);
for (var index = 0; index < batchSessions.Count; index++)
{
await conn.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @ScheduledAt,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new
{
SessionId = batchSessions[index].Id,
ScheduledAt = newSchedule[index]
},
transaction);
}
await transaction.CommitAsync();
var firstSession = batchSessions[0];
if (firstSession.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(batchId, firstSession.TelegramChatId, firstSession.BatchMessageId.Value, firstSession.Title);
}
var notification = $"🔄 <b>Мастер обновил расписание пачки</b>\n\n" +
$"📌 <b>{System.Net.WebUtility.HtmlEncode(firstSession.Title)}</b>\n" +
$"🗓 Новое начало: <b>{firstScheduledAt.FormatMoscow()}</b> (МСК)\n" +
$"↔️ Шаг: <b>{intervalDays} дн.</b>";
await bot.SendMessage(firstSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var recipients = await LoadBatchDirectRecipientsAsync(conn, batchId);
await SendDirectNotificationsAsync(recipients, notification, "web-batch-rescheduled", batchId);
}
}
public async Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var sourceSessions = (await conn.QueryAsync<WebBatchSessionRow>(
"""
SELECT s.id AS Id,
s.group_id AS GroupId,
s.title AS Title,
s.join_link AS JoinLink,
s.scheduled_at AS ScheduledAt,
s.status AS Status,
s.max_players AS MaxPlayers,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.batch_id = @BatchId
AND s.group_id = @GroupId
ORDER BY s.scheduled_at
FOR UPDATE
""",
new { BatchId = batchId, GroupId = groupId },
transaction)).ToList();
if (sourceSessions.Count == 0)
{
throw new SessionAccessDeniedException(batchId, 0);
}
var newBatchId = Guid.NewGuid();
var batchTitle = sourceSessions[0].Title;
var batchJoinLink = sourceSessions[0].JoinLink;
var chatId = sourceSessions[0].TelegramChatId;
var threadId = sourceSessions[0].ThreadId;
var renderedSessions = new List<SessionBatchDto>();
foreach (var sourceSession in sourceSessions)
{
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
var sessionId = await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers, @NotificationMode)
RETURNING id
""",
new
{
BatchId = newBatchId,
sourceSession.GroupId,
Title = batchTitle,
JoinLink = batchJoinLink,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
ThreadId = threadId,
sourceSession.MaxPlayers,
sourceSession.NotificationMode
},
transaction);
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers));
}
await transaction.CommitAsync();
var renderResult = SessionBatchRenderer.Render(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup);
await conn.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
new { MessageId = batchMessage.MessageId, BatchId = newBatchId });
return new WebSessionBatch(
newBatchId,
groupId,
batchTitle,
batchJoinLink,
renderedSessions.Min(session => session.ScheduledAt),
renderedSessions.Max(session => session.ScheduledAt),
renderedSessions.Count,
sourceSessions[0].NotificationMode);
}
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
Npgsql.NpgsqlConnection conn,
Guid sessionId)
{
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
"""
SELECT p.telegram_id AS TelegramId,
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 = @Active
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
}
private async Task<List<WebDirectNotificationRecipient>> LoadBatchDirectRecipientsAsync(
Npgsql.NpgsqlConnection conn,
Guid batchId)
{
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
"""
SELECT DISTINCT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
JOIN sessions s ON s.id = sp.session_id
WHERE s.batch_id = @BatchId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { BatchId = batchId, Active = ParticipantRegistrationStatus.Active })).ToList();
}
private async Task SendDirectNotificationsAsync(
IEnumerable<WebDirectNotificationRecipient> recipients,
string htmlText,
string notificationKind,
Guid entityId)
{
foreach (var recipient in recipients)
{
try
{
await bot.SendMessage(
chatId: recipient.TelegramId,
text: htmlText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to send {NotificationKind} DM for entity {EntityId} to player {TelegramId} ({DisplayName})",
notificationKind,
entityId,
recipient.TelegramId,
recipient.DisplayName);
}
}
}
private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title)
{
try
@@ -119,16 +823,19 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync();
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();
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
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.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();
var renderResult = SessionBatchRenderer.Render(title, sessions, participants);
@@ -145,4 +852,30 @@ public sealed class SessionService(
logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
}
}
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
Npgsql.NpgsqlConnection conn,
Guid batchId,
Guid groupId,
Npgsql.NpgsqlTransaction transaction)
{
return await conn.QuerySingleOrDefaultAsync<WebBatchInfo>(
"""
SELECT s.batch_id AS BatchId,
s.group_id AS GroupId,
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
g.telegram_chat_id AS TelegramChatId,
(array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId,
(array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId,
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.batch_id = @BatchId
AND s.group_id = @GroupId
GROUP BY s.batch_id, s.group_id, g.telegram_chat_id
""",
new { BatchId = batchId, GroupId = groupId },
transaction);
}
}
+76 -1
View File
@@ -1,5 +1,5 @@
/* ============================================
GM-Relay Design System v1.1.0
GM-Relay Design System v1.7.0
Dark RPG Dashboard Theme
============================================ */
@@ -363,6 +363,11 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
filter: invert(0.7);
}
select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* === Tables === */
.gm-table {
width: 100%;
@@ -553,6 +558,66 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
gap: 1rem;
}
/* === Batch bulk operations === */
.batch-bulk-grid {
display: grid;
gap: 1rem;
margin-bottom: 1.5rem;
}
.batch-bulk-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: 1.25rem;
}
.batch-bulk-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.batch-bulk-header h3 {
font-size: 1rem;
margin-bottom: 0.25rem;
overflow-wrap: anywhere;
}
.batch-bulk-header p {
margin: 0;
color: var(--text-muted);
font-size: 0.8125rem;
}
.batch-bulk-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.batch-bulk-divider {
height: 1px;
background: var(--border-color);
margin: 1rem 0;
}
.batch-clone-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
margin-top: 1rem;
}
.batch-clone-row .btn-gm {
white-space: nowrap;
}
/* === Animations === */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
@@ -772,6 +837,16 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
grid-template-columns: 1fr;
}
.batch-bulk-fields,
.batch-clone-row {
grid-template-columns: 1fr;
}
.batch-clone-row .btn-gm {
justify-content: center;
width: 100%;
}
.page-container {
padding: 1rem;
}
@@ -0,0 +1,16 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Domain;
public sealed class SessionNotificationModeTests
{
[Theory]
[InlineData(SessionNotificationMode.GroupAndDirect, true)]
[InlineData(SessionNotificationMode.GroupOnly, false)]
public void ShouldSendDirectMessages_ReturnsExpectedDecision(
SessionNotificationMode mode,
bool expected)
{
Assert.Equal(expected, mode.ShouldSendDirectMessages());
}
}
@@ -13,6 +13,7 @@ public sealed class NewSessionCommandParserTests
Название: Curse of Strahd
Время: 24.04.2026 19:30
Время: 01.05.2026 20:00
Мест: 4
Ссылка: https://example.test/room
""";
@@ -21,6 +22,7 @@ public sealed class NewSessionCommandParserTests
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),
@@ -65,4 +67,22 @@ public sealed class NewSessionCommandParserTests
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));
}
}
@@ -5,28 +5,115 @@ namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleTimeInputHandlerTests
{
[Fact]
public void BuildVotingMessage_ShouldShowApprovedAndPendingParticipants()
public void TryParseVotingInput_ShouldAcceptTwoOptionsAndDeadline()
{
var approvedId = Guid.NewGuid();
var pendingId = Guid.NewGuid();
var now = new DateTimeOffset(2026, 4, 24, 8, 0, 0, TimeSpan.Zero);
var ok = RescheduleVotingInput.TryParse(
"""
25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00
""",
now,
out var input,
out var error);
Assert.True(ok, error);
Assert.Equal(2, input.Options.Count);
Assert.Equal(new DateTimeOffset(2026, 4, 25, 16, 30, 0, TimeSpan.Zero), input.Options[0]);
Assert.Equal(new DateTimeOffset(2026, 4, 26, 15, 0, 0, TimeSpan.Zero), input.Options[1]);
Assert.Equal(new DateTimeOffset(2026, 4, 25, 9, 0, 0, TimeSpan.Zero), input.Deadline);
}
[Fact]
public void TryParseVotingInput_ShouldRejectSingleOption()
{
var now = new DateTimeOffset(2026, 4, 24, 8, 0, 0, TimeSpan.Zero);
var ok = RescheduleVotingInput.TryParse(
"""
25.04.2026 19:30
Дедлайн: 25.04.2026 12:00
""",
now,
out _,
out var error);
Assert.False(ok);
Assert.Equal("Укажите от 2 до 3 вариантов времени.", error);
}
[Fact]
public void BuildVotingMessage_ShouldShowOptionsDeadlineVotesAndPendingParticipants()
{
var firstOptionId = Guid.NewGuid();
var secondOptionId = Guid.NewGuid();
var aliceId = Guid.NewGuid();
var bobId = Guid.NewGuid();
var charlieId = 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 deadline = new DateTimeOffset(2026, 4, 25, 9, 0, 0, TimeSpan.Zero);
var options = new List<RescheduleOptionDto>
{
new(firstOptionId, 1, new DateTimeOffset(2026, 4, 26, 16, 0, 0, TimeSpan.Zero)),
new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero))
};
var participants = new List<VoteParticipantDto>
{
new(approvedId, "Alice", "alice"),
new(pendingId, "Bob", null)
new(aliceId, "Alice", "alice"),
new(bobId, "Bob", null),
new(charlieId, "Charlie", null)
};
var votes = new List<RescheduleOptionVoteDto>
{
new(firstOptionId, aliceId, "Alice", "alice"),
new(secondOptionId, bobId, "Bob", null)
};
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage(
"Shadowrun",
currentTime,
newTime,
deadline,
options,
participants,
[approvedId]);
votes);
Assert.Contains("Shadowrun", text);
Assert.Contains("✅ @alice", text);
Assert.Contains("⏳ Bob", text);
Assert.Contains("Голоса: 1/2 ✅", text);
Assert.Contains("Дедлайн: <b>25 апреля 2026, 12:00</b> (МСК)", text);
Assert.Contains("1. <b>26 апреля 2026, 19:00</b> (МСК) — 1 голос", text);
Assert.Contains("@alice", text);
Assert.Contains("2. <b>27 апреля 2026, 20:00</b> (МСК) — 1 голос", text);
Assert.Contains("Bob", text);
Assert.Contains("Не проголосовали: Charlie", text);
Assert.Contains("Голосов: 2/3", text);
}
[Fact]
public void BuildVotingKeyboard_ShouldCreateOneButtonPerOption()
{
var firstOptionId = Guid.NewGuid();
var secondOptionId = Guid.NewGuid();
var options = new List<RescheduleOptionDto>
{
new(firstOptionId, 1, new DateTimeOffset(2026, 4, 26, 16, 0, 0, TimeSpan.Zero)),
new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero))
};
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
var buttons = keyboard.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Collection(
buttons,
button =>
{
Assert.Equal("1. 26.04 19:00", button.Text);
Assert.Equal($"reschedule_vote:{firstOptionId}", button.CallbackData);
},
button =>
{
Assert.Equal("2. 27.04 20:00", button.Text);
Assert.Equal($"reschedule_vote:{secondOptionId}", button.CallbackData);
});
}
}
@@ -5,32 +5,50 @@ namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class RescheduleVoteRulesTests
{
[Fact]
public void Evaluate_ShouldReject_WhenParticipantVotesNo()
public void SelectWinner_ShouldApproveSingleTopOption()
{
var decision = RescheduleVoteRules.Evaluate("no", totalParticipants: 4, approvedParticipants: 3);
var winningOptionId = Guid.NewGuid();
var otherOptionId = Guid.NewGuid();
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);
var decision = RescheduleVoteRules.SelectWinner(
[
new RescheduleOptionVoteCount(winningOptionId, 3),
new RescheduleOptionVoteCount(otherOptionId, 1)
]);
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
Assert.True(decision.ShouldRescheduleSession);
Assert.True(decision.ShouldResetParticipantRsvps);
Assert.Equal(winningOptionId, decision.SelectedOptionId);
Assert.Equal("Победил вариант с большинством голосов.", decision.Reason);
}
[Fact]
public void Evaluate_ShouldStayPending_WhileVotesOutstanding()
public void SelectWinner_ShouldRejectTie()
{
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 5, approvedParticipants: 2);
var firstOptionId = Guid.NewGuid();
var secondOptionId = Guid.NewGuid();
Assert.Equal(RescheduleVoteOutcome.Pending, decision.Outcome);
Assert.False(decision.ShouldRescheduleSession);
Assert.False(decision.ShouldResetParticipantRsvps);
var decision = RescheduleVoteRules.SelectWinner(
[
new RescheduleOptionVoteCount(firstOptionId, 2),
new RescheduleOptionVoteCount(secondOptionId, 2)
]);
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
Assert.Null(decision.SelectedOptionId);
Assert.Equal("Голоса разделились поровну, перенос не применяется.", decision.Reason);
}
[Fact]
public void SelectWinner_ShouldRejectWhenNobodyVoted()
{
var decision = RescheduleVoteRules.SelectWinner(
[
new RescheduleOptionVoteCount(Guid.NewGuid(), 0),
new RescheduleOptionVoteCount(Guid.NewGuid(), 0)
]);
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
Assert.Null(decision.SelectedOptionId);
Assert.Equal("Никто не проголосовал до дедлайна, перенос не применяется.", decision.Reason);
}
}
@@ -14,14 +14,15 @@ public sealed class SessionBatchRendererTests
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned)
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"),
new ParticipantBatchDto(cancelledSessionId, "Bob", null)
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);
@@ -35,16 +36,23 @@ public sealed class SessionBatchRendererTests
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(2, result.Markup.InlineKeyboard.Count());
Assert.Equal(4, result.Markup.InlineKeyboard.Count());
Assert.Collection(
buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"leave_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData));
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"promote_waitlist:{secondSessionId}", callbackData));
}
}
@@ -1,4 +1,5 @@
using GmRelay.Web.Services;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Web;
@@ -16,7 +17,7 @@ public sealed class AuthorizedSessionServiceTests
],
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);
@@ -27,6 +28,34 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal("Session A", sessions[0].Title);
}
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenUserIsCoGm()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId);
Assert.NotNull(sessions);
Assert.Single(sessions);
Assert.Equal("Session A", sessions[0].Title);
}
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
{
@@ -56,7 +85,7 @@ public sealed class AuthorizedSessionServiceTests
],
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);
@@ -78,7 +107,7 @@ public sealed class AuthorizedSessionServiceTests
],
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);
@@ -99,11 +128,11 @@ public sealed class AuthorizedSessionServiceTests
],
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 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);
Assert.False(store.UpdateCalled);
@@ -123,11 +152,11 @@ public sealed class AuthorizedSessionServiceTests
],
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);
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.Equal(groupId, store.LastUpdatedGroupId);
@@ -135,24 +164,350 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal("Updated", store.LastUpdatedTitle);
Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt);
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);
}
[Fact]
public async Task UpdateBatchDetailsForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
{
var batchId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b");
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateBatchDetailsCalled);
}
[Fact]
public async Task UpdateBatchDetailsForGmAsync_UpdatesOwnedBatch()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId);
Assert.Equal(groupId, store.LastUpdatedBatchGroupId);
Assert.Equal("Updated", store.LastUpdatedBatchTitle);
Assert.Equal("https://example.test/b", store.LastUpdatedBatchJoinLink);
}
[Fact]
public async Task UpdateBatchDetailsForGmAsync_UpdatesBatch_WhenUserIsCoGm()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId);
Assert.Equal(groupId, store.LastUpdatedBatchGroupId);
}
[Fact]
public async Task AddCoGmForOwnerAsync_AddsCoGm_WhenUserIsOwner()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
]);
var service = new AuthorizedSessionService(store);
await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant");
Assert.True(store.AddCoGmCalled);
Assert.Equal(groupId, store.LastAddedCoGmGroupId);
Assert.Equal(coGmId, store.LastAddedCoGmTelegramId);
Assert.Equal("Assistant GM", store.LastAddedCoGmDisplayName);
Assert.Equal("assistant", store.LastAddedCoGmUsername);
}
[Fact]
public async Task AddCoGmForOwnerAsync_Throws_WhenUserIsCoGm()
{
var ownerId = 1001L;
var coGmId = 2002L;
var newCoGmId = 3003L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.AddCoGmCalled);
}
[Fact]
public async Task RemoveCoGmForOwnerAsync_RemovesCoGm_WhenUserIsOwner()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId);
Assert.True(store.RemoveCoGmCalled);
Assert.Equal(groupId, store.LastRemovedCoGmGroupId);
Assert.Equal(coGmId, store.LastRemovedCoGmTelegramId);
}
[Fact]
public async Task UpdateBatchNotificationModeForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
{
var batchId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateBatchNotificationModeCalled);
}
[Fact]
public async Task UpdateBatchNotificationModeForGmAsync_UpdatesOwnedBatch()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly);
Assert.True(store.UpdateBatchNotificationModeCalled);
Assert.Equal(batchId, store.LastUpdatedNotificationBatchId);
Assert.Equal(groupId, store.LastUpdatedNotificationGroupId);
Assert.Equal(SessionNotificationMode.GroupOnly, store.LastUpdatedNotificationMode);
}
[Fact]
public async Task RescheduleBatchForGmAsync_RejectsNonPositiveInterval()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(action);
Assert.False(store.RescheduleBatchCalled);
}
[Fact]
public async Task RescheduleBatchForGmAsync_ReschedulesOwnedBatch()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var firstScheduledAt = DateTime.UtcNow.AddDays(7);
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14);
Assert.True(store.RescheduleBatchCalled);
Assert.Equal(batchId, store.LastRescheduledBatchId);
Assert.Equal(groupId, store.LastRescheduledBatchGroupId);
Assert.Equal(firstScheduledAt, store.LastRescheduledFirstScheduledAt);
Assert.Equal(14, store.LastRescheduledIntervalDays);
}
[Fact]
public async Task CloneBatchForGmAsync_ClonesOwnedBatch()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek);
Assert.True(store.CloneBatchCalled);
Assert.Equal(batchId, store.LastClonedBatchId);
Assert.Equal(groupId, store.LastClonedBatchGroupId);
Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval);
}
private sealed class FakeSessionStore(
IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null) : ISessionStore
IEnumerable<WebSession>? sessions = null,
IEnumerable<FakeGroupManager>? managers = null) : ISessionStore
{
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
private readonly List<FakeGroupManager> managers = managers?.ToList() ?? [];
public bool UpdateCalled { get; private set; }
public bool PromoteCalled { get; private set; }
public bool UpdateBatchDetailsCalled { get; private set; }
public bool UpdateBatchNotificationModeCalled { get; private set; }
public bool RescheduleBatchCalled { get; private set; }
public bool CloneBatchCalled { get; private set; }
public bool AddCoGmCalled { get; private set; }
public bool RemoveCoGmCalled { get; private set; }
public Guid? LastUpdatedSessionId { get; private set; }
public Guid? LastUpdatedGroupId { get; private set; }
public string? LastUpdatedTitle { get; private set; }
public DateTime? LastUpdatedScheduledAt { 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 Guid? LastUpdatedBatchId { get; private set; }
public Guid? LastUpdatedBatchGroupId { get; private set; }
public string? LastUpdatedBatchTitle { get; private set; }
public string? LastUpdatedBatchJoinLink { get; private set; }
public Guid? LastUpdatedNotificationBatchId { get; private set; }
public Guid? LastUpdatedNotificationGroupId { get; private set; }
public SessionNotificationMode? LastUpdatedNotificationMode { get; private set; }
public Guid? LastRescheduledBatchId { get; private set; }
public Guid? LastRescheduledBatchGroupId { get; private set; }
public DateTime? LastRescheduledFirstScheduledAt { get; private set; }
public int? LastRescheduledIntervalDays { get; private set; }
public Guid? LastClonedBatchId { get; private set; }
public Guid? LastClonedBatchGroupId { get; private set; }
public BatchCloneInterval? LastCloneInterval { get; private set; }
public Guid? LastAddedCoGmGroupId { get; private set; }
public long? LastAddedCoGmTelegramId { get; private set; }
public string? LastAddedCoGmDisplayName { get; private set; }
public string? LastAddedCoGmUsername { get; private set; }
public Guid? LastRemovedCoGmGroupId { get; private set; }
public long? LastRemovedCoGmTelegramId { get; private set; }
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList());
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());
public Task<WebGameGroup?> GetGroupAsync(Guid groupId)
{
@@ -160,6 +515,36 @@ public sealed class AuthorizedSessionServiceTests
return Task.FromResult(group);
}
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsManager(groupId, telegramId));
public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsOwner(groupId, telegramId));
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
{
if (!groupsById.TryGetValue(groupId, out var group))
{
return Task.FromResult(new List<WebGroupManager>());
}
var result = new List<WebGroupManager>
{
new(group.GmTelegramId, "Owner GM", null, GroupManagerRoleExtensions.OwnerValue, DateTime.UtcNow)
};
result.AddRange(managers
.Where(manager => manager.GroupId == groupId)
.Select(manager => new WebGroupManager(
manager.TelegramId,
$"Co-GM {manager.TelegramId}",
null,
manager.Role.ToDatabaseValue(),
DateTime.UtcNow)));
return Task.FromResult(result);
}
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) =>
Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());
@@ -169,7 +554,30 @@ public sealed class AuthorizedSessionServiceTests
return Task.FromResult(session);
}
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink)
public Task<WebSessionBatch?> GetBatchAsync(Guid batchId)
{
var batchSessions = sessionsById.Values
.Where(session => session.BatchId == batchId)
.OrderBy(session => session.ScheduledAt)
.ToList();
if (batchSessions.Count == 0)
{
return Task.FromResult<WebSessionBatch?>(null);
}
var firstSession = batchSessions[0];
return Task.FromResult<WebSessionBatch?>(new(
batchId,
firstSession.GroupId,
firstSession.Title,
firstSession.JoinLink,
firstSession.ScheduledAt,
batchSessions[^1].ScheduledAt,
batchSessions.Count));
}
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{
UpdateCalled = true;
LastUpdatedSessionId = sessionId;
@@ -177,7 +585,88 @@ public sealed class AuthorizedSessionServiceTests
LastUpdatedTitle = title;
LastUpdatedScheduledAt = scheduledAt;
LastUpdatedJoinLink = joinLink;
LastUpdatedMaxPlayers = maxPlayers;
return Task.CompletedTask;
}
public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
{
PromoteCalled = true;
LastPromotedSessionId = sessionId;
LastPromotedGroupId = groupId;
return Task.CompletedTask;
}
public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
{
UpdateBatchDetailsCalled = true;
LastUpdatedBatchId = batchId;
LastUpdatedBatchGroupId = groupId;
LastUpdatedBatchTitle = title;
LastUpdatedBatchJoinLink = joinLink;
return Task.CompletedTask;
}
public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
{
UpdateBatchNotificationModeCalled = true;
LastUpdatedNotificationBatchId = batchId;
LastUpdatedNotificationGroupId = groupId;
LastUpdatedNotificationMode = notificationMode;
return Task.CompletedTask;
}
public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays)
{
RescheduleBatchCalled = true;
LastRescheduledBatchId = batchId;
LastRescheduledBatchGroupId = groupId;
LastRescheduledFirstScheduledAt = firstScheduledAt;
LastRescheduledIntervalDays = intervalDays;
return Task.CompletedTask;
}
public Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval)
{
CloneBatchCalled = true;
LastClonedBatchId = batchId;
LastClonedBatchGroupId = groupId;
LastCloneInterval = interval;
return Task.FromResult(new WebSessionBatch(
Guid.NewGuid(),
groupId,
"Session A",
"https://example.test/a",
DateTime.UtcNow.AddDays(7),
DateTime.UtcNow.AddDays(7),
1));
}
public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
AddCoGmCalled = true;
LastAddedCoGmGroupId = groupId;
LastAddedCoGmTelegramId = coGmTelegramId;
LastAddedCoGmDisplayName = displayName;
LastAddedCoGmUsername = telegramUsername;
return Task.CompletedTask;
}
public Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
{
RemoveCoGmCalled = true;
LastRemovedCoGmGroupId = groupId;
LastRemovedCoGmTelegramId = coGmTelegramId;
return Task.CompletedTask;
}
private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
private bool IsOwner(Guid groupId, long telegramId) =>
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId;
}
private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);
}
@@ -0,0 +1,51 @@
using GmRelay.Web.Services;
namespace GmRelay.Bot.Tests.Web;
public sealed class BatchSchedulePlannerTests
{
[Fact]
public void BuildFixedIntervalSchedule_OrdersSessionsAndAppliesInterval()
{
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var currentSchedule = new[]
{
new DateTime(2026, 4, 28, 16, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 4, 21, 16, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 5, 5, 16, 0, 0, DateTimeKind.Utc)
};
var result = BatchSchedulePlanner.BuildFixedIntervalSchedule(currentSchedule, firstScheduledAt, intervalDays: 7);
Assert.Equal(
[
firstScheduledAt,
firstScheduledAt.AddDays(7),
firstScheduledAt.AddDays(14)
],
result);
}
[Fact]
public void BuildFixedIntervalSchedule_RejectsNonPositiveInterval()
{
var currentSchedule = new[] { new DateTime(2026, 4, 28, 16, 0, 0, DateTimeKind.Utc) };
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var action = () => BatchSchedulePlanner.BuildFixedIntervalSchedule(currentSchedule, firstScheduledAt, intervalDays: 0);
Assert.Throws<ArgumentOutOfRangeException>(action);
}
[Theory]
[InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)]
[InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)]
public void ShiftForClone_AppliesRequestedCalendarShift(BatchCloneInterval interval, int expectedYear, int expectedMonth, int expectedDay)
{
var scheduledAt = new DateTime(2026, 5, 1, 16, 30, 0, DateTimeKind.Utc);
var result = BatchSchedulePlanner.ShiftForClone(scheduledAt, interval);
Assert.Equal(new DateTime(expectedYear, expectedMonth, expectedDay, 16, 30, 0, DateTimeKind.Utc), result);
}
}
@@ -0,0 +1,31 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class WebStylesTests
{
[Fact]
public async Task AppCss_ShouldStyleNativeSelectOptionsForReadableDropdowns()
{
var css = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/wwwroot/app.css"));
Assert.Matches(
@"select\s+option\s*\{[^}]*background:\s*var\(--bg-secondary\);[^}]*color:\s*var\(--text-primary\);",
css);
}
private static string FindRepositoryFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}