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