13 Commits

Author SHA1 Message Date
Toutsu 9c91057798 feat: add session capacity waitlist
Deploy Telegram Bot / build-and-push (push) Failing after 4m42s
Deploy Telegram Bot / deploy (push) Has been skipped
2026-04-24 13:28:01 +03:00
Toutsu 675ac1226e chore: make compose config portable
Deploy Telegram Bot / build-and-push (push) Successful in 40s
Deploy Telegram Bot / deploy (push) Successful in 17s
2026-04-24 10:44:33 +03:00
Toutsu b80002aa36 refactor: unify session status model
Deploy Telegram Bot / build-and-push (push) Successful in 4m47s
Deploy Telegram Bot / deploy (push) Successful in 19s
Fixes #5
2026-04-24 10:26:45 +03:00
Toutsu bb8cbb7a40 test: cover core bot and web scenarios
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / deploy (push) Successful in 20s
2026-04-23 21:08:41 +03:00
Toutsu 93e7c1ac66 chore: поднятие версии до 1.1.2 во всех файлах конфигурации
Deploy Telegram Bot / build-and-push (push) Successful in 42s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-23 20:49:01 +03:00
Toutsu 4d6651827b fix: skip stale pending updates on startup
Deploy Telegram Bot / build-and-push (push) Successful in 4m24s
Deploy Telegram Bot / deploy (push) Successful in 18s
2026-04-23 20:42:16 +03:00
Toutsu 9e7a202f42 fix: redact bot secrets in startup logs
Deploy Telegram Bot / build-and-push (push) Successful in 4m24s
Deploy Telegram Bot / deploy (push) Successful in 21s
2026-04-23 20:28:52 +03:00
Toutsu 1c4cfb71c0 fix: close web access to foreign groups and sessions
Deploy Telegram Bot / build-and-push (push) Successful in 7m25s
Deploy Telegram Bot / deploy (push) Successful in 18s
2026-04-23 20:09:22 +03:00
Toutsu ecc2236937 chore: поднятие версии до 1.1.1 во всех файлах конфигурации
Deploy Telegram Bot / build-and-push (push) Successful in 25s
Deploy Telegram Bot / deploy (push) Successful in 8s
2026-04-21 15:47:43 +03:00
Toutsu 3002db6534 fix: загрузка Telegram Login Widget через JS interop для корректной работы с Blazor SPA-навигацией
Deploy Telegram Bot / build-and-push (push) Successful in 3m17s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-04-21 15:31:12 +03:00
Toutsu 176f1105ab v1.1.0: Полный редизайн фронтенда, усиление безопасности и обновление версии
Deploy Telegram Bot / build-and-push (push) Successful in 5m19s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-21 15:21:18 +03:00
Toutsu b6af5f047c Bump version to 1.0.1
Deploy Telegram Bot / build-and-push (push) Successful in 30s
Deploy Telegram Bot / deploy (push) Successful in 8s
2026-04-20 17:02:03 +03:00
Toutsu 66e7f5eea7 Решение проблемы с таймзонами
Deploy Telegram Bot / build-and-push (push) Successful in 1m4s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-20 14:36:53 +03:00
70 changed files with 3753 additions and 822 deletions
+3
View File
@@ -8,3 +8,6 @@ TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
+2 -2
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.0.0
VERSION: 1.2.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -70,4 +70,4 @@ jobs:
docker compose pull bot web
# Запускаем! Флаг -d оставит их работать в фоне.
docker compose up -d
docker compose up -d
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.0.0</Version>
<Version>1.2.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+12 -3
View File
@@ -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
View File
@@ -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.0.0
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.0.0
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,26 +103,27 @@ public sealed class HandleRsvpHandler(
new { command.SessionId },
transaction);
// ── 4. Handle decline ───────────────────────────────────────
if (command.Status == RsvpStatus.Declined)
{
// Revert session to ConfirmationSent if it was Confirmed
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
if (decision.ShouldRevertSessionToConfirmationSent)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
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,38 +140,38 @@ 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>(
"""
SELECT
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
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
{
@@ -260,16 +232,18 @@ public sealed class HandleRsvpHandler(
var participants = (await connection.QueryAsync<ParticipantRsvp>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus
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,49 +14,47 @@ public sealed class CreateSessionHandler(
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
var text = message.Text ?? "";
string? title = null;
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
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;
var chatTitle = message.Chat.Title ?? "Private Chat";
@@ -67,20 +63,24 @@ public sealed class CreateSessionHandler(
try
{
// 1. Убеждаемся, что GM зарегистрирован
await connection.ExecuteAsync(
@"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;",
"""
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;
""",
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)
VALUES (@ChatId, @ChatName, @GmId)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING 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;
""",
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, "Ошибка при добавлении игрока к сессии");
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Произошла ошибка при регистрации.", cancellationToken: ct);
if (!transactionCommitted)
{
await transaction.RollbackAsync(ct);
}
var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
}
}
}
@@ -0,0 +1,104 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult(
string? Title,
string? Link,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0;
}
internal static class NewSessionCommandParser
{
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
private static readonly string[] SeatLimitPrefixes =
[
"\u041c\u0435\u0441\u0442:",
"\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
];
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
int? maxPlayers = null;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
{
title = line[TitlePrefix.Length..].Trim();
continue;
}
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
{
link = line[LinkPrefix.Length..].Trim();
continue;
}
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (seatLimitPrefix is not null)
{
var seatLimitInput = line[seatLimitPrefix.Length..].Trim();
if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0)
{
maxPlayers = parsedMaxPlayers;
}
else
{
invalidSeatLimitInputs.Add(seatLimitInput);
}
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var timeInput = line[TimePrefix.Length..].Trim();
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
{
invalidTimeInputs.Add(timeInput);
continue;
}
if (scheduledAt <= nowUtc)
{
pastTimeInputs.Add(timeInput);
continue;
}
scheduledTimes.Add(scheduledAt);
}
return new NewSessionParseResult(
title,
link,
maxPlayers,
scheduledTimes,
pastTimeInputs,
invalidTimeInputs,
invalidSeatLimitInputs);
}
}
@@ -0,0 +1,185 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record PromoteWaitlistedPlayerCommand(
Guid SessionId,
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int MessageId);
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, long GmId, int? MaxPlayers);
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
public sealed class PromoteWaitlistedPlayerHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<PromoteWaitlistedPlayerHandler> logger)
{
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
try
{
var session = await connection.QuerySingleOrDefaultAsync<PromoteWaitlistSessionDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.max_players AS MaxPlayers,
g.gm_telegram_id AS GmId
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId
FOR UPDATE
""",
new { command.SessionId },
transaction);
if (session is null)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
return;
}
if (session.GmId != command.TelegramUserId)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
return;
}
var activeParticipants = await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM session_participants
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
var waitlistedParticipants = await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM session_participants
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Waitlisted
""",
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
if (waitlistedParticipants == 0)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct);
return;
}
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct);
return;
}
var promoted = await connection.QuerySingleAsync<WaitlistedParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
ORDER BY sp.created_at ASC, sp.id ASC
LIMIT 1
FOR UPDATE OF sp
""",
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET registration_status = @Active,
rsvp_status = @Pending,
responded_at = NULL
WHERE id = @ParticipantRowId
""",
new
{
promoted.ParticipantRowId,
Active = ParticipantRegistrationStatus.Active,
Pending = RsvpStatus.Pending
},
transaction);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"""
SELECT id AS SessionId,
scheduled_at AS ScheduledAt,
status AS Status,
max_players AS MaxPlayers
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at
""",
new { session.BatchId },
transaction)).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { session.BatchId },
transaction)).ToList();
await transaction.CommitAsync(ct);
transactionCommitted = true;
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при повышении игрока из листа ожидания для сессии {SessionId}", command.SessionId);
if (!transactionCommitted)
{
await transaction.RollbackAsync(ct);
}
var errorText = transactionCommitted
? "Игрок повышен, но не удалось обновить сообщение расписания."
: "Ошибка при обновлении листа ожидания.";
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
}
}
}
@@ -1,5 +1,6 @@
using System.Text;
using 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;
@@ -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();
@@ -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
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending', responded_at = NULL
WHERE session_id = @SessionId AND is_gm = false
""",
new { proposal.SessionId },
transaction);
if (decision.ShouldResetParticipantRsvps)
{
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId AND is_gm = false
AND registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
}
await 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);
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.1" />
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
@@ -0,0 +1,47 @@
using System.Text.RegularExpressions;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Logging;
public static partial class SecretRedactor
{
public static string RedactConnectionString(string? connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
return string.Empty;
}
try
{
var builder = new NpgsqlConnectionStringBuilder(connectionString);
if (!string.IsNullOrWhiteSpace(builder.Password))
{
builder.Password = "***";
}
return builder.ToString();
}
catch (ArgumentException)
{
return RedactText(connectionString);
}
}
public static string RedactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return SecretKeyValueRegex().Replace(
text,
static match => $"{match.Groups["key"].Value}={GetRedactedValue()}");
}
private static string GetRedactedValue() => "***";
[GeneratedRegex(@"(?<key>password|pwd|passwd|token|secret|api[-_]?key)\s*=\s*(?<value>[^;\s,]+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex SecretKeyValueRegex();
}
@@ -0,0 +1,8 @@
using Telegram.Bot.Types;
namespace GmRelay.Bot.Infrastructure.Telegram;
public interface ITelegramUpdateHandler
{
Task RouteAsync(Update update, CancellationToken ct);
}
@@ -0,0 +1,14 @@
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Infrastructure.Telegram;
public interface ITelegramUpdateSource
{
Task<Update[]> GetUpdatesAsync(
int offset,
int? limit = null,
int? timeout = null,
IEnumerable<UpdateType>? allowedUpdates = null,
CancellationToken cancellationToken = default);
}
@@ -1,4 +1,3 @@
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
@@ -9,35 +8,21 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
/// Stateless — all state is in PostgreSQL. Safe to restart at any time.
/// </summary>
public sealed class TelegramBotService(
ITelegramBotClient bot,
UpdateRouter router,
ITelegramUpdateSource updateSource,
ITelegramUpdateHandler updateHandler,
ILogger<TelegramBotService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Telegram bot polling started");
// Skip any pending updates from before this startup
try
{
var pending = await bot.GetUpdates(offset: -1, limit: 1, cancellationToken: stoppingToken);
if (pending.Length > 0)
{
logger.LogInformation("Skipped {Count} pending update(s)", pending[^1].Id);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to clear pending updates, continuing anyway");
}
var offset = 0;
var offset = await GetStartupOffsetAsync(stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var updates = await bot.GetUpdates(
var updates = await updateSource.GetUpdatesAsync(
offset: offset,
timeout: 30,
allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
@@ -47,7 +32,7 @@ public sealed class TelegramBotService(
{
try
{
await router.RouteAsync(update, stoppingToken);
await updateHandler.RouteAsync(update, stoppingToken);
}
catch (Exception ex)
{
@@ -70,4 +55,33 @@ public sealed class TelegramBotService(
logger.LogInformation("Telegram bot polling stopped");
}
private async Task<int> GetStartupOffsetAsync(CancellationToken stoppingToken)
{
try
{
var pending = await updateSource.GetUpdatesAsync(
offset: -1,
limit: 1,
cancellationToken: stoppingToken);
if (pending.Length == 0)
{
return 0;
}
var startupOffset = pending[^1].Id + 1;
logger.LogInformation(
"Skipping pending updates through {LastPendingUpdateId}; starting polling from offset {StartupOffset}",
pending[^1].Id,
startupOffset);
return startupOffset;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to determine startup offset, continuing from offset 0");
return 0;
}
}
}
@@ -0,0 +1,21 @@
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramUpdateSource(ITelegramBotClient bot) : ITelegramUpdateSource
{
public Task<Update[]> GetUpdatesAsync(
int offset,
int? limit = null,
int? timeout = null,
IEnumerable<UpdateType>? allowedUpdates = null,
CancellationToken cancellationToken = default) =>
bot.GetUpdates(
offset: offset,
limit: limit,
timeout: timeout,
allowedUpdates: allowedUpdates,
cancellationToken: cancellationToken);
}
@@ -20,6 +20,7 @@ public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
CancelSessionHandler cancelSessionHandler,
DeleteSessionHandler deleteSessionHandler,
ListSessionsHandler listSessionsHandler,
@@ -28,7 +29,7 @@ public sealed class UpdateRouter(
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot,
ILogger<UpdateRouter> logger)
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{
public async Task RouteAsync(Update update, CancellationToken ct)
{
@@ -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';
+11 -2
View File
@@ -4,6 +4,7 @@ using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Bot.Infrastructure.Logging;
using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram;
using Npgsql;
@@ -20,11 +21,16 @@ builder.AddServiceDefaults();
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var connectionString = config.GetConnectionString("gmrelaydb")
?? throw new InvalidOperationException(
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
Console.WriteLine($"[DBG] Master ConnectionString => {connectionString}");
var logger = loggerFactory.CreateLogger("GmRelay.Bot.Startup");
logger.LogInformation(
"Configured PostgreSQL data source with connection string {ConnectionString}",
SecretRedactor.RedactConnectionString(connectionString));
return NpgsqlDataSource.Create(connectionString);
});
@@ -40,6 +46,7 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
"Telegram:BotToken is required. Set via environment variable Telegram__BotToken or appsettings.json.");
return new TelegramBotClient(token);
});
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
// ── Feature handlers (explicit registration — AOT safe) ──────────────
builder.Services.AddSingleton<SendConfirmationHandler>();
@@ -47,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>();
@@ -57,6 +65,7 @@ builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
// ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>();
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
builder.Services.AddHostedService<TelegramBotService>();
// ── Session scheduler ────────────────────────────────────────────────
@@ -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>
+1 -1
View File
@@ -11,7 +11,7 @@ public static class MoscowTime
public static string FormatMoscow(this DateTimeOffset utc)
=> utc.ToOffset(MoscowOffset).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
public static DateTime ToMoscow(this DateTime utcDt) => utcDt.Add(MoscowOffset);
public static DateTime ToMoscow(this DateTime utcDt) => DateTime.SpecifyKind(utcDt.Add(MoscowOffset), DateTimeKind.Unspecified);
public static string FormatMoscow(this DateTime utcDt)
=> utcDt.Add(MoscowOffset).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
@@ -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}";
}
}
+22 -3
View File
@@ -1,12 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="GM-Relay — панель управления для Мастеров Игры. Управляйте сессиями настольных ролевых игр через Telegram." />
<meta name="theme-color" content="#0a0e1a" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" />
<ImportMap />
@@ -18,6 +22,21 @@
<Routes @rendermode="InteractiveServer" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script>
window.loadTelegramWidget = function (botUsername, authUrl) {
var container = document.getElementById('telegram-login-container');
if (!container) return;
container.innerHTML = '';
var script = document.createElement('script');
script.async = true;
script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large');
script.setAttribute('data-auth-url', authUrl);
script.setAttribute('data-request-access', 'write');
container.appendChild(script);
};
</script>
</body>
</html>
@@ -1,19 +1,15 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<aside class="sidebar">
<NavMenu />
</div>
</aside>
<main>
<div class="top-row px-4">
<a href="https://github.com/Toutsu/GmRelayBot" target="_blank">О проекте</a>
</div>
<article class="content px-4">
<div class="main-area">
<article class="content">
@Body
</article>
</main>
</div>
</div>
<div id="blazor-error-ui" data-nosnippet>
@@ -1,86 +1,39 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
min-height: 100vh;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
width: var(--sidebar-width);
background: linear-gradient(180deg, #0f1629 0%, #1a0a2e 100%);
border-right: 1px solid var(--border-color);
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 100;
display: flex;
align-items: center;
flex-direction: column;
transition: transform var(--transition-smooth);
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
.main-area {
flex: 1;
margin-left: var(--sidebar-width);
min-height: 100vh;
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
.content {
padding: 1.5rem 2rem;
max-width: 100%;
}
/* === Error UI === */
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.3);
box-sizing: border-box;
display: none;
left: 0;
@@ -88,11 +41,44 @@ main {
position: fixed;
width: 100%;
z-index: 1000;
color: var(--text-secondary);
font-size: 0.875rem;
}
#blazor-error-ui .reload {
color: var(--accent-secondary);
margin-left: 0.5rem;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
/* === Mobile Responsive === */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
width: 280px;
}
.sidebar.open {
transform: translateX(0);
}
.main-area {
margin-left: 0;
}
.content {
padding: 1rem;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.content {
padding: 1.25rem 1.5rem;
}
}
+68 -36
View File
@@ -1,41 +1,73 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">GM-Relay Web</a>
</div>
@inject NavigationManager Navigation
<div class="nav-header">
<a class="nav-brand" href="">
<span class="nav-brand-icon">🎲</span>
<span class="nav-brand-text">GM-Relay</span>
</a>
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Навигационное меню">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<input type="checkbox" title="Навигационное меню" class="navbar-toggler" />
<nav class="nav-body @(isOpen ? "open" : "")">
<AuthorizeView>
<Authorized>
<div class="nav-section">
<NavLink class="nav-item" href="" Match="NavLinkMatch.All" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Панель управления
</NavLink>
</div>
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<AuthorizeView>
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Панель управления
</NavLink>
</div>
<div class="nav-item px-3 mt-auto">
<div class="nav-link text-light">
<span class="bi bi-person-fill" aria-hidden="true"></span> @context.User.Identity?.Name
<div class="nav-footer">
<div class="nav-user">
<div class="nav-user-avatar">
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
</div>
<span class="nav-user-name">@context.User.Identity?.Name</span>
</div>
<div class="nav-item px-3">
<form action="/auth/logout" method="post">
<AntiforgeryToken />
<button type="submit" class="nav-link btn btn-link text-light text-start w-100 p-0 shadow-none border-0">
<span class="bi bi-box-arrow-right" aria-hidden="true"></span> Выйти
</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="login">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Войти
</NavLink>
</div>
</NotAuthorized>
</AuthorizeView>
</nav>
</div>
<form action="/auth/logout" method="post">
<AntiforgeryToken />
<button type="submit" class="nav-logout-btn">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1-2 2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Выйти
</button>
</form>
<div class="nav-version">v1.2.0</div>
</div>
</Authorized>
<NotAuthorized>
<div class="nav-section">
<NavLink class="nav-item" href="login" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
Войти
</NavLink>
</div>
</NotAuthorized>
</AuthorizeView>
</nav>
@code {
private bool isOpen;
private void ToggleMenu() => isOpen = !isOpen;
private void CloseMenu() => isOpen = false;
}
@@ -1,105 +1,194 @@
.navbar-toggler {
appearance: none;
/* === Nav Header === */
.nav-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.625rem;
text-decoration: none;
color: var(--text-primary);
}
.nav-brand-icon {
font-size: 1.5rem;
}
.nav-brand-text {
font-size: 1.125rem;
font-weight: 700;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-toggle {
display: none;
background: none;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
padding: 0.375rem;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
transition: all var(--transition-fast);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
.nav-toggle:hover {
background: var(--bg-surface);
color: var(--text-primary);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
/* === Nav Body === */
.nav-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 0.75rem 0;
overflow-y: auto;
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
.nav-section {
padding: 0 0.75rem;
flex: 1;
}
/* === Nav Items === */
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all var(--transition-normal);
margin-bottom: 0.125rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item.active,
.nav-item ::deep a.active {
background: rgba(124, 58, 237, 0.15);
color: var(--accent-primary);
border: 1px solid rgba(124, 58, 237, 0.2);
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
.nav-icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
}
/* === Nav Footer === */
.nav-footer {
padding: 0.75rem;
border-top: 1px solid var(--border-color);
margin-top: auto;
}
.nav-user {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.5rem 0.75rem;
}
.nav-user-avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--accent-gradient);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.nav-user-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-logout-btn {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.5rem 0.875rem;
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-family: 'Inter', sans-serif;
font-size: 0.8125rem;
cursor: pointer;
transition: all var(--transition-normal);
}
.nav-logout-btn:hover {
background: var(--status-danger-bg);
color: var(--status-danger);
border-color: rgba(239, 68, 68, 0.15);
}
.nav-version {
text-align: center;
font-size: 0.6875rem;
color: var(--text-muted);
padding-top: 0.5rem;
opacity: 0.6;
}
/* === Mobile === */
@media (max-width: 768px) {
.nav-toggle {
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
justify-content: center;
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 200;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
width: 2.5rem;
height: 2.5rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
.nav-body {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
.nav-body.open {
display: flex;
}
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
.nav-header {
padding-right: 0.75rem;
}
}
@media (min-width: 769px) {
.nav-body {
height: calc(100vh - 4.5rem);
}
}
@@ -0,0 +1,27 @@
@page "/access-denied"
<PageTitle>Доступ запрещен — GM-Relay</PageTitle>
<div class="page-container">
<div class="glass-card" style="max-width: 640px;">
<div class="empty-state">
<div class="empty-state-icon">⛔</div>
<div class="empty-state-title">Доступ запрещен</div>
<p class="empty-state-text">Эта группа или сессия недоступна для вашей учётной записи.</p>
<a href="/" class="btn-gm btn-gm-primary">← На главную</a>
</div>
</div>
</div>
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
protected override void OnInitialized()
{
if (HttpContext is not null && !HttpContext.Response.HasStarted)
{
HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
}
}
}
@@ -2,61 +2,77 @@
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject SessionService SessionService
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Редактирование сессии - GM-Relay</PageTitle>
<PageTitle>Редактирование сессии GM-Relay</PageTitle>
<div class="container mt-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Главная</a></li>
<li class="breadcrumb-item active">Редактирование сессии</li>
</ol>
</nav>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Редактирование сессии</li>
</ul>
<h2>Редактирование сессии</h2>
<div class="page-header animate-fade-in">
<h2>✏️ Редактирование сессии</h2>
</div>
@if (session == null)
{
<p>Загрузка деталей сессии...</p>
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 1.5rem;"></div>
<div class="skeleton skeleton-text" style="width: 100%; height: 2.5rem; margin-bottom: 1.5rem;"></div>
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 1.5rem;"></div>
<div class="skeleton skeleton-text" style="width: 100%; height: 2.5rem; margin-bottom: 1.5rem;"></div>
<div class="skeleton skeleton-text" style="width: 30%; height: 2.5rem;"></div>
</div>
}
else
{
<div class="card shadow-sm mt-4">
<div class="card-body">
<EditForm Model="@model" OnValidSubmit="HandleSubmit">
<div class="mb-3">
<label class="form-label font-weight-bold">Название игры</label>
<InputText @bind-Value="model.Title" class="form-control" placeholder="например, D&D 5e: Dragon's Hoard" />
<div class="form-text">Изменение этого поля обновит все сессии в одной группе.</div>
</div>
<div class="glass-card animate-slide-up" style="max-width: 640px;">
<EditForm Model="@model" OnValidSubmit="HandleSubmit">
<div class="gm-form-group">
<label class="gm-form-label">Название игры</label>
<InputText @bind-Value="model.Title" class="gm-form-control" placeholder="например, D&D 5e: Dragon's Hoard" />
<div class="gm-form-hint">Изменение этого поля обновит все сессии в одной группе.</div>
</div>
<div class="mb-3">
<label class="form-label font-weight-bold">Запланированное время (МСК UTC+3)</label>
<input type="datetime-local" @bind="model.ScheduledAtLocal" class="form-control" />
<div class="form-text">Текущее: @session.ScheduledAt.FormatMoscow()</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Запланированное время (МСК, UTC+3)</label>
<input type="datetime-local" @bind="model.ScheduledAtLocal" class="gm-form-control" />
<div class="gm-form-hint">Текущее: @session.ScheduledAt.FormatMoscow()</div>
</div>
<div class="mb-3">
<label class="form-label font-weight-bold">Ссылка для подключения</label>
<InputText @bind-Value="model.JoinLink" class="form-control" placeholder="Ссылка на Discord или VTT" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Ссылка для подключения</label>
<InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
</div>
<div class="mt-4">
<button type="submit" class="btn btn-success" disabled="@isSubmitting">
@(isSubmitting ? "Сохранение..." : "Сохранить изменения")
</button>
<button type="button" class="btn btn-outline-secondary ms-2" @onclick="GoBack">Отмена</button>
</div>
</EditForm>
</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 ? "⏳ Сохранение..." : "✅ Сохранить изменения")
</button>
<button type="button" class="btn-gm btn-gm-outline" @onclick="GoBack">
Отмена
</button>
</div>
</EditForm>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger mt-3">@errorMessage</div>
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem; max-width: 640px;">
⚠️ @errorMessage
</div>
}
}
</div>
@@ -65,19 +81,29 @@
[Parameter] public Guid SessionId { get; set; }
private WebSession? session;
private SessionEditModel model = new();
private bool isSubmitting = false;
private bool isSubmitting;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
session = await SessionService.GetSessionAsync(SessionId);
if (session != null)
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
{
model.Title = session.Title;
// Convert UTC to Moscow for the picker
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink;
Navigation.NavigateTo("/access-denied");
return;
}
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
model.Title = session.Title;
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink;
model.MaxPlayers = session.MaxPlayers;
}
private async Task HandleSubmit()
@@ -87,13 +113,22 @@
try
{
// The value from <input type="datetime-local"> is considered as "unspecified" or local to browser.
// We treat it as Moscow time (UTC+3) and convert to UTC.
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionAsync(SessionId, model.Title, utcTime, model.JoinLink);
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
Navigation.NavigateTo($"/group/{session!.GroupId}");
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось сохранить изменения: " + ex.Message;
@@ -111,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; }
}
}
+18 -20
View File
@@ -1,28 +1,26 @@
@page "/Error"
@page "/Error"
@using System.Diagnostics
<PageTitle>Ошибка</PageTitle>
<PageTitle>Ошибка — GM-Relay</PageTitle>
<h1 class="text-danger">Ошибка.</h1>
<h2 class="text-danger">Произошла ошибка при обработке вашего запроса.</h2>
<div class="page-container">
<div class="error-page">
<div class="error-page-icon">⚠️</div>
<h1 class="error-page-title">Произошла ошибка</h1>
<p class="error-page-text">При обработке вашего запроса что-то пошло не так. Пожалуйста, попробуйте снова.</p>
@if (ShowRequestId)
{
<p>
<strong>ID запроса:</strong> <code>@RequestId</code>
</p>
}
@if (ShowRequestId)
{
<p style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
ID запроса: @RequestId
</p>
}
<h3>Режим разработки</h3>
<p>
Переключение на среду <strong>Development</strong> отобразит более подробную информацию о произошедшей ошибке.
</p>
<p>
<strong>Среда Development не должна быть включена для развернутых приложений.</strong>
Это может привести к отображению конфиденциальной информации из исключений конечным пользователям.
Для локальной отладки включите среду <strong>Development</strong>, установив переменную среды <strong>ASPNETCORE_ENVIRONMENT</strong> в значение <strong>Development</strong>
и перезапустите приложение.
</p>
<a href="/" class="btn-gm btn-gm-primary" style="margin-top: 0.5rem;">
← На главную
</a>
</div>
</div>
@code{
[CascadingParameter]
@@ -2,88 +2,215 @@
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject SessionService SessionService
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Сессии группы - GM-Relay</PageTitle>
<PageTitle>Сессии группы GM-Relay</PageTitle>
<div class="container mt-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Главная</a></li>
<li class="breadcrumb-item active">Сессии группы</li>
</ol>
</nav>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Сессии группы</li>
</ul>
<h2>Предстоящие игры</h2>
<div class="mt-4">
@if (sessions == null)
{
<p>Загрузка сессий...</p>
}
else if (sessions.Count == 0)
{
<div class="alert alert-info">Для этой группы не найдено предстоящих сессий.</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Время (МСК)</th>
<th>Статус</th>
<th>Ссылка</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
@foreach (var session in sessions)
{
<tr>
<td>@session.Title</td>
<td>@session.ScheduledAt.FormatMoscow()</td>
<td>
<span class="badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</td>
<td><a href="@session.JoinLink" target="_blank" class="text-truncate d-inline-block" style="max-width: 150px;">Ссылка</a></td>
<td>
<a href="/session/edit/@session.Id" class="btn btn-sm btn-outline-secondary">Изменить</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="page-header animate-fade-in">
<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;">
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 50%;"></div>
</div>
}
else if (sessions.Count == 0)
{
<div class="glass-card">
<div class="empty-state">
<div class="empty-state-icon">🎯</div>
<div class="empty-state-title">Нет предстоящих сессий</div>
<p class="empty-state-text">Для этой группы пока не запланировано игровых сессий.</p>
</div>
</div>
}
else
{
@* Desktop table *@
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
<table class="gm-table">
<thead>
<tr>
<th>Название</th>
<th>Время (МСК)</th>
<th>Места</th>
<th>Статус</th>
<th>Ссылка</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
@foreach (var session in sessions)
{
<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>
<td>
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer"
style="max-width: 150px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
Подключиться ↗
</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>
}
</tbody>
</table>
</div>
@* Mobile cards *@
<div class="session-card-mobile stagger-children">
@foreach (var session in sessions)
{
<div class="session-card">
<div class="session-card-header">
<span class="session-card-title">@session.Title</span>
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</div>
<div class="session-card-body">
<div class="session-card-row">
<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>
</div>
</div>
<div class="session-card-actions">
<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>
}
</div>
}
</div>
@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()
{
sessions = await SessionService.GetUpcomingSessionsAsync(GroupId);
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
if (sessions is null)
{
Navigation.NavigateTo("/access-denied");
}
}
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 => "bg-success",
SessionStatus.Cancelled => "bg-danger",
SessionStatus.ConfirmationSent => "bg-warning text-dark",
_ => "bg-secondary"
SessionStatus.Confirmed => "status-success",
SessionStatus.Cancelled => "status-danger",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private string TranslateStatus(string status) => status switch
{
"Recruiting" => "Набор",
"RecruitmentClosed" => "Набор закрыт",
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения",
SessionStatus.ConfirmationSent => "Ждём подтверждения",
SessionStatus.Confirmed => "Подтверждено",
SessionStatus.Cancelled => "Отменено",
_ => status
+70 -34
View File
@@ -3,48 +3,82 @@
@using Microsoft.AspNetCore.Components.Authorization
@using GmRelay.Web.Services
@attribute [Authorize]
@inject SessionService SessionService
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Панель управления - GM-Relay</PageTitle>
<PageTitle>Панель управления GM-Relay</PageTitle>
<div class="container mt-4">
<h2>Добро пожаловать, @userName!</h2>
<p class="text-muted">Выберите группу для управления играми.</p>
<div class="page-container">
<div class="page-header animate-fade-in">
<h2>Добро пожаловать, @userName! 👋</h2>
<p>Выберите группу для управления игровыми сессиями.</p>
</div>
<div class="row mt-4">
@if (groups == null)
{
<p>Загрузка групп...</p>
}
else if (groups.Count == 0)
{
<div class="col-12">
<div class="card bg-light">
<div class="card-body text-center">
<p class="mb-0">У вас еще нет зарегистрированных групп. Сначала добавьте бота в группу Telegram!</p>
</div>
</div>
@if (groups == null)
{
<div class="card-grid">
@for (int i = 0; i < 3; i++)
{
<div class="skeleton skeleton-card"></div>
}
</div>
}
else if (groups.Count == 0)
{
<div class="glass-card">
<div class="empty-state">
<div class="empty-state-icon">🤖</div>
<div class="empty-state-title">Нет зарегистрированных групп</div>
<p class="empty-state-text">Добавьте бота GM-Relay в свою группу Telegram, чтобы начать управлять игровыми сессиями.</p>
</div>
}
else
{
</div>
}
else
{
<div class="card-grid stagger-children">
@foreach (var group in groups)
{
<div class="col-md-4 mb-3">
<div class="card h-100 shadow-sm">
<div class="card-body">
<h5 class="card-title">@group.Name</h5>
<p class="card-text text-muted">ID: @group.TelegramChatId</p>
<a href="/group/@group.Id" class="btn btn-primary">Посмотреть игры</a>
</div>
</div>
<div class="glass-card group-card">
<div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @group.TelegramChatId</p>
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
Посмотреть игры
</a>
</div>
}
}
</div>
</div>
}
</div>
<style>
.group-card {
display: flex;
flex-direction: column;
min-height: 180px;
}
.group-card-icon {
font-size: 2rem;
margin-bottom: 0.75rem;
}
.group-card-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--text-primary);
}
.group-card-id {
font-size: 0.75rem;
color: var(--text-muted);
font-family: 'Courier New', monospace;
margin-bottom: 1rem;
}
</style>
@code {
private List<WebGameGroup>? groups;
private string userName = "";
@@ -55,10 +89,12 @@
var user = authState.User;
userName = user.Identity?.Name ?? "Мастер Игры";
var telegramIdClaim = user.FindFirst("TelegramId")?.Value;
if (long.TryParse(telegramIdClaim, out var telegramId))
if (!user.TryGetTelegramId(out var telegramId))
{
groups = await SessionService.GetGroupsForGmAsync(telegramId);
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
}
}
+22 -21
View File
@@ -2,31 +2,24 @@
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation
@inject IConfiguration Configuration
@inject IJSRuntime JS
<PageTitle>Вход - GM-Relay</PageTitle>
<PageTitle>Вход GM-Relay</PageTitle>
<div class="container">
<div class="row justify-content-center mt-5">
<div class="col-md-6 text-center">
<h3>Панель управления GM-Relay</h3>
<p class="text-muted">Пожалуйста, войдите как Мастер Игры для управления сессиями.</p>
<div class="login-page">
<div class="login-card">
<div class="login-logo">🎲</div>
<h1 class="login-title">GM-Relay</h1>
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
<div class="mt-4">
@if (Navigation.Uri.Contains("error=auth_failed"))
{
<div class="alert alert-danger">Ошибка аутентификации. Пожалуйста, попробуйте снова.</div>
}
@* Telegram Login Widget *@
<div id="telegram-login-container">
<script async src="https://telegram.org/js/telegram-widget.js?22"
data-telegram-login="@BotUsername"
data-size="large"
data-auth-url="@AuthUrl"
data-request-access="write"></script>
</div>
@if (Navigation.Uri.Contains("error=auth_failed"))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1.5rem; justify-content: center;">
⚠️ Ошибка аутентификации. Пожалуйста, попробуйте снова.
</div>
</div>
}
<div id="telegram-login-container"></div>
</div>
</div>
@@ -48,4 +41,12 @@
}
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("loadTelegramWidget", BotUsername, AuthUrl);
}
}
}
@@ -1,5 +1,13 @@
@page "/not-found"
@page "/not-found"
@layout MainLayout
<h3>Не найдено</h3>
<p>Извините, страница, которую вы ищете, не существует.</p>
<PageTitle>404 — GM-Relay</PageTitle>
<div class="error-page">
<div class="error-page-icon">🔍</div>
<h1 class="error-page-title">Страница не найдена</h1>
<p class="error-page-text">Извините, страница, которую вы ищете, не существует или была перемещена.</p>
<a href="/" class="btn-gm btn-gm-primary">
← Вернуться на главную
</a>
</div>
+18 -2
View File
@@ -21,7 +21,8 @@ builder.AddNpgsqlDataSource("gmrelaydb");
// Add Services
builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.AddSingleton<SessionService>();
builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
// Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
@@ -32,12 +33,17 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
return new TelegramBotClient(token);
});
// Add Authentication
// Add Authentication with hardened cookie settings
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.AccessDeniedPath = "/access-denied";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization();
@@ -58,6 +64,16 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
// Security headers middleware
app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
await next();
});
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
@@ -0,0 +1,56 @@
namespace GmRelay.Web.Services;
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
{
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
sessionStore.GetGroupsForGmAsync(gmId);
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
return await sessionStore.GetUpcomingSessionsAsync(groupId);
}
public async Task<WebSession?> GetSessionForGmAsync(Guid sessionId, long gmId)
{
var session = await sessionStore.GetSessionAsync(sessionId);
if (session is null)
{
return null;
}
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
}
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
}
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)
{
var group = await sessionStore.GetGroupAsync(groupId);
return group?.GmTelegramId == gmId;
}
}
@@ -0,0 +1,9 @@
using System.Security.Claims;
namespace GmRelay.Web.Services;
public static class ClaimsPrincipalExtensions
{
public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) =>
long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId);
}
+11
View File
@@ -0,0 +1,11 @@
namespace GmRelay.Web.Services;
public interface ISessionStore
{
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
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, int? maxPlayers);
Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId);
}
@@ -0,0 +1,4 @@
namespace GmRelay.Web.Services;
public sealed class SessionAccessDeniedException(Guid sessionId, long gmId)
: InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'.");
+222 -30
View File
@@ -7,11 +7,26 @@ 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,
ITelegramBotClient bot)
ITelegramBotClient bot,
ILogger<SessionService> logger) : ISessionStore
{
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
{
@@ -21,56 +36,132 @@ public sealed class SessionService(
new { GmId = gmId })).ToList();
}
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId });
}
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
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)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
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, 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();
// 1. Fetch current session with all required columns for WebSession record
var oldSession = await conn.QuerySingleAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
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",
new { Id = sessionId }, transaction);
// 2. Update Session
await conn.ExecuteAsync(
@"UPDATE sessions SET title = @Title, scheduled_at = @ScheduledAt, join_link = @JoinLink, updated_at = now()
WHERE id = @Id",
new { Id = sessionId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink },
WHERE s.id = @Id AND s.group_id = @GroupId",
new { Id = sessionId, GroupId = groupId },
transaction);
// 3. Update all sessions in the same batch with new title (optional, usually batch shares title)
if (oldSession is null)
{
throw new SessionAccessDeniedException(sessionId, 0);
}
var updatedRows = await conn.ExecuteAsync(
@"UPDATE sessions
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,
MaxPlayers = maxPlayers
},
transaction);
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(sessionId, 0);
}
await conn.ExecuteAsync(
"UPDATE sessions SET title = @Title WHERE batch_id = @BatchId",
new { Title = title, BatchId = oldSession.BatchId },
@@ -78,21 +169,119 @@ public sealed class SessionService(
await transaction.CommitAsync();
// 4. Send Telegram Notification
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);
// 5. Update Original Batch Message
if (oldSession.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
}
}
public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount
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
@@ -100,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);
@@ -121,9 +313,9 @@ public sealed class SessionService(
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup);
}
catch (Exception)
catch (Exception ex)
{
// Ignore if message too old or same content
logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
}
}
}
@@ -26,19 +26,18 @@ public sealed class TelegramAuthService(IConfiguration configuration)
var dataCheckString = string.Join("\n", dataCheckList);
// 2. Compute Secret Key
using var sha256 = SHA256.Create();
var secretKey = sha256.ComputeHash(Encoding.UTF8.GetBytes(token));
// 2. Compute Secret Key (static method — no IDisposable needed)
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(token));
// 3. Compute Hash
using var hmac = new HMACSHA256(secretKey);
var computedHashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataCheckString));
var computedHash = Convert.ToHexString(computedHashBytes).ToLower();
// 3. Compute Hash (static method — no IDisposable needed)
var computedHashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
if (computedHash != hash.ToString().ToLower())
// 4. Timing-safe comparison to prevent timing attacks
var hashBytes = Convert.FromHexString(hash.ToString());
if (!CryptographicOperations.FixedTimeEquals(computedHashBytes, hashBytes))
return false;
// 4. Check expiration (auth_date)
// 5. Check expiration (auth_date)
if (query.TryGetValue("auth_date", out var authDateStr) && long.TryParse(authDateStr, out var authDate))
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+796 -32
View File
@@ -1,60 +1,824 @@
/* ============================================
GM-Relay Design System v1.2.0
Dark RPG Dashboard Theme
============================================ */
/* --- Google Fonts loaded in App.razor --- */
/* === CSS Custom Properties === */
:root {
/* Background */
--bg-primary: #0a0e1a;
--bg-secondary: #111827;
--bg-card: rgba(17, 24, 39, 0.7);
--bg-card-hover: rgba(24, 33, 54, 0.85);
--bg-surface: rgba(255, 255, 255, 0.04);
--bg-input: rgba(255, 255, 255, 0.06);
/* Accent */
--accent-primary: #7c3aed;
--accent-primary-hover: #6d28d9;
--accent-secondary: #06b6d4;
--accent-gradient: linear-gradient(135deg, #7c3aed 0%, #06b6d4 100%);
--accent-gradient-hover: linear-gradient(135deg, #6d28d9 0%, #0891b2 100%);
/* Text */
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-accent: #a78bfa;
/* Status */
--status-success: #22c55e;
--status-success-bg: rgba(34, 197, 94, 0.15);
--status-warning: #f59e0b;
--status-warning-bg: rgba(245, 158, 11, 0.15);
--status-danger: #ef4444;
--status-danger-bg: rgba(239, 68, 68, 0.15);
--status-info: #06b6d4;
--status-info-bg: rgba(6, 182, 212, 0.15);
--status-neutral: #64748b;
--status-neutral-bg: rgba(100, 116, 139, 0.15);
/* Border */
--border-color: rgba(255, 255, 255, 0.08);
--border-glow: rgba(124, 58, 237, 0.4);
/* Glass */
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-blur: 16px;
/* Shadows */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(124, 58, 237, 0.2);
--shadow-glow-hover: 0 0 30px rgba(124, 58, 237, 0.35);
/* Radius */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
/* Transition */
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
--transition-smooth: 0.35s cubic-bezier(0.4, 0, 0.2, 1);
/* Sidebar */
--sidebar-width: 260px;
}
/* === Reset & Base === */
*, *::before, *::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
margin: 0;
padding: 0;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a, .btn-link {
color: #006bb7;
/* === Typography === */
h1, h2, h3, h4, h5, h6 {
color: var(--text-primary);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.3;
margin-top: 0;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
h1 { font-size: 1.875rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h1:focus { outline: none; }
p { line-height: 1.6; }
a {
color: var(--accent-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
a:hover {
color: #22d3ee;
}
.content {
padding-top: 1.1rem;
/* === Scrollbar === */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
h1:focus {
outline: none;
::-webkit-scrollbar-track {
background: transparent;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.invalid {
outline: 1px solid #e50000;
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.validation-message {
color: #e50000;
/* === Glass Card === */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 1.5rem;
transition: all var(--transition-smooth);
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
.glass-card:hover {
background: var(--bg-card-hover);
border-color: var(--border-glow);
box-shadow: var(--shadow-glow-hover);
transform: translateY(-2px);
}
/* === Buttons === */
.btn-gm {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: none;
border-radius: var(--radius-sm);
font-family: 'Inter', sans-serif;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-normal);
text-decoration: none;
line-height: 1.4;
}
.btn-gm-primary {
background: var(--accent-gradient);
color: white;
box-shadow: 0 2px 12px rgba(124, 58, 237, 0.3);
}
.btn-gm-primary:hover {
background: var(--accent-gradient-hover);
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.45);
transform: translateY(-1px);
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
.btn-gm-primary:active {
transform: translateY(0);
}
.btn-gm-success {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
box-shadow: 0 2px 12px rgba(34, 197, 94, 0.3);
}
.btn-gm-success:hover {
box-shadow: 0 4px 20px rgba(34, 197, 94, 0.45);
transform: translateY(-1px);
color: white;
}
.btn-gm-outline {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.btn-gm-outline:hover {
background: var(--bg-surface);
color: var(--text-primary);
border-color: var(--glass-border);
}
.btn-gm-danger {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
}
.btn-gm-danger:hover {
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4);
transform: translateY(-1px);
color: white;
}
.btn-gm[disabled],
.btn-gm:disabled {
opacity: 0.5;
pointer-events: none;
}
/* === Status Badges === */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
}
.status-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.status-success {
background: var(--status-success-bg);
color: var(--status-success);
border: 1px solid rgba(34, 197, 94, 0.25);
}
.status-success::before { background: var(--status-success); box-shadow: 0 0 6px var(--status-success); }
.status-warning {
background: var(--status-warning-bg);
color: var(--status-warning);
border: 1px solid rgba(245, 158, 11, 0.25);
}
.status-warning::before { background: var(--status-warning); box-shadow: 0 0 6px var(--status-warning); }
.status-danger {
background: var(--status-danger-bg);
color: var(--status-danger);
border: 1px solid rgba(239, 68, 68, 0.25);
}
.status-danger::before { background: var(--status-danger); box-shadow: 0 0 6px var(--status-danger); }
.status-info {
background: var(--status-info-bg);
color: var(--status-info);
border: 1px solid rgba(6, 182, 212, 0.25);
}
.status-info::before { background: var(--status-info); box-shadow: 0 0 6px var(--status-info); }
.status-neutral {
background: var(--status-neutral-bg);
color: var(--status-neutral);
border: 1px solid rgba(100, 116, 139, 0.25);
}
.status-neutral::before { background: var(--status-neutral); }
/* === Form Controls === */
.gm-form-control {
width: 100%;
padding: 0.625rem 0.875rem;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: 'Inter', sans-serif;
font-size: 0.875rem;
transition: all var(--transition-normal);
outline: none;
}
.gm-form-control:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
background: rgba(255, 255, 255, 0.08);
}
.gm-form-control::placeholder {
color: var(--text-muted);
}
.gm-form-label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.01em;
}
.gm-form-hint {
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.gm-form-group {
margin-bottom: 1.25rem;
}
/* Override Blazor InputText styling */
.form-control,
input[type="text"],
input[type="datetime-local"],
input[type="email"],
input[type="url"],
input[type="number"],
textarea,
select {
background: var(--bg-input) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--radius-sm) !important;
color: var(--text-primary) !important;
font-family: 'Inter', sans-serif !important;
font-size: 0.875rem !important;
padding: 0.625rem 0.875rem !important;
transition: all var(--transition-normal) !important;
}
.form-control:focus,
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent-primary) !important;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15) !important;
background: rgba(255, 255, 255, 0.08) !important;
outline: none !important;
}
/* Color scheme for date/time pickers */
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
filter: invert(0.7);
}
/* === Tables === */
.gm-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.875rem;
}
.gm-table thead th {
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
background: transparent;
}
.gm-table tbody td {
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border-color);
color: var(--text-secondary);
vertical-align: middle;
}
.gm-table tbody tr {
transition: background var(--transition-fast);
}
.gm-table tbody tr:hover {
background: var(--bg-surface);
}
.gm-table tbody tr:last-child td {
border-bottom: none;
}
/* === Alerts === */
.gm-alert {
padding: 0.875rem 1.125rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.625rem;
}
.gm-alert-info {
background: var(--status-info-bg);
border: 1px solid rgba(6, 182, 212, 0.2);
color: var(--status-info);
}
.gm-alert-danger {
background: var(--status-danger-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--status-danger);
}
.gm-alert-success {
background: var(--status-success-bg);
border: 1px solid rgba(34, 197, 94, 0.2);
color: var(--status-success);
}
/* === Breadcrumb === */
.gm-breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0 0 1.5rem 0;
font-size: 0.8125rem;
}
.gm-breadcrumb li {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-muted);
}
.gm-breadcrumb li + li::before {
content: '';
color: var(--text-muted);
font-size: 1rem;
}
.gm-breadcrumb a {
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.gm-breadcrumb a:hover {
color: var(--accent-secondary);
}
.gm-breadcrumb .active {
color: var(--text-primary);
}
/* === Loading Skeleton === */
.skeleton {
background: linear-gradient(90deg,
var(--bg-surface) 25%,
rgba(255, 255, 255, 0.08) 50%,
var(--bg-surface) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
.skeleton-card {
height: 160px;
border-radius: var(--radius-lg);
}
.skeleton-text {
height: 1rem;
width: 60%;
margin-bottom: 0.5rem;
}
.skeleton-text-sm {
height: 0.75rem;
width: 40%;
}
/* === Empty State === */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
text-align: center;
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.empty-state-text {
font-size: 0.875rem;
color: var(--text-muted);
max-width: 320px;
}
/* === Page Container === */
.page-container {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
animation: fadeIn 0.4s ease-out;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h2 {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.page-header p {
color: var(--text-muted);
font-size: 0.875rem;
margin: 0;
}
/* === Grid === */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* === Animations === */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 15px rgba(124, 58, 237, 0.15); }
50% { box-shadow: 0 0 25px rgba(124, 58, 237, 0.3); }
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out both;
}
.animate-slide-up {
animation: slideUp 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
}
/* Stagger children animation */
.stagger-children > * {
animation: fadeIn 0.4s ease-out both;
}
.stagger-children > *:nth-child(1) { animation-delay: 0.05s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.1s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.15s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.2s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.25s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.3s; }
/* === Blazor Overrides === */
.valid.modified:not([type=checkbox]) {
outline: none;
border-color: var(--status-success) !important;
}
.invalid {
outline: none;
border-color: var(--status-danger) !important;
}
.validation-message {
color: var(--status-danger);
font-size: 0.75rem;
margin-top: 0.25rem;
}
.blazor-error-boundary {
background: var(--status-danger-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
padding: 1rem 1.25rem;
color: var(--status-danger);
border-radius: var(--radius-md);
margin: 1rem 0;
}
.blazor-error-boundary::after {
content: "Произошла ошибка при отображении этого компонента."
}
/* Bootstrap overrides for dark theme */
.form-label {
color: var(--text-secondary) !important;
font-size: 0.8125rem !important;
font-weight: 500 !important;
}
.form-text {
color: var(--text-muted) !important;
font-size: 0.75rem !important;
}
/* === Login page background === */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
.login-page::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(ellipse at 30% 50%, rgba(124, 58, 237, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at 70% 50%, rgba(6, 182, 212, 0.08) 0%, transparent 50%);
animation: bg-drift 20s ease-in-out infinite alternate;
}
@keyframes bg-drift {
0% { transform: translate(0, 0) rotate(0deg); }
100% { transform: translate(-3%, 2%) rotate(3deg); }
}
.login-card {
position: relative;
z-index: 1;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: 2.5rem;
width: 100%;
max-width: 400px;
margin: 1rem;
text-align: center;
animation: slideUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.login-logo {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.login-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-subtitle {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 2rem;
}
/* === Mobile Sessions Cards (instead of table) === */
.session-card-mobile {
display: none;
}
@media (max-width: 768px) {
.session-table-desktop {
display: none;
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
.session-card-mobile {
display: block;
}
.session-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: 1rem;
margin-bottom: 0.75rem;
transition: all var(--transition-smooth);
}
.session-card:hover {
border-color: var(--border-glow);
}
.session-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.session-card-title {
font-weight: 600;
font-size: 0.9375rem;
color: var(--text-primary);
}
.session-card-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.session-card-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.session-card-actions {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
display: flex;
gap: 0.5rem;
}
.card-grid {
grid-template-columns: 1fr;
}
.page-container {
padding: 1rem;
}
h2 {
font-size: 1.25rem;
}
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
/* === 404 / Error Pages === */
.error-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: 2rem;
animation: fadeIn 0.5s ease-out;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
.error-page-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.error-page-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.error-page-text {
color: var(--text-muted);
font-size: 0.9375rem;
max-width: 400px;
margin-bottom: 1.5rem;
}
/* === Responsive fine-tuning === */
@media (max-width: 480px) {
.login-card {
padding: 1.5rem;
margin: 0.75rem;
}
.glass-card {
padding: 1rem;
}
}
@@ -0,0 +1,75 @@
using System.Reflection;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Domain;
public sealed class SessionStatusTests
{
[Fact]
public void All_ShouldContainOnlyCanonicalSessionStatuses()
{
var allProperty = typeof(SessionStatus).GetProperty(
"All",
BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(allProperty);
var allStatusValues = Assert.IsAssignableFrom<IReadOnlySet<string>>(allProperty.GetValue(null));
var expectedStatusValues = new[]
{
SessionStatus.Planned,
SessionStatus.ConfirmationSent,
SessionStatus.Confirmed,
SessionStatus.Cancelled
}
.Order(StringComparer.Ordinal);
Assert.Equal(expectedStatusValues, allStatusValues.Order(StringComparer.Ordinal));
}
[Fact]
public void ProductionSources_ShouldNotReferenceLegacySessionStatuses()
{
var repositoryRoot = FindRepositoryRoot();
var productionFiles = Directory.EnumerateFiles(repositoryRoot, "*.*", SearchOption.AllDirectories)
.Where(path => IsProductionSource(path))
.ToList();
var legacyStatuses = new[] { "Recruit" + "ing", "Recruitment" + "Closed" };
var offenders = productionFiles
.SelectMany(path => legacyStatuses
.Where(status => File.ReadAllText(path).Contains(status, StringComparison.Ordinal))
.Select(status => $"{Path.GetRelativePath(repositoryRoot, path)} contains {status}"))
.ToList();
Assert.Empty(offenders);
}
private static bool IsProductionSource(string path)
{
var extension = Path.GetExtension(path);
var separator = Path.DirectorySeparatorChar;
return path.Contains($"{separator}src{separator}", StringComparison.Ordinal)
&& !path.Contains($"{separator}bin{separator}", StringComparison.Ordinal)
&& !path.Contains($"{separator}obj{separator}", StringComparison.Ordinal)
&& extension is ".cs" or ".razor" or ".sql";
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "GM-Relay.slnx")))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Could not find repository root.");
}
}
@@ -0,0 +1,50 @@
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Features.Confirmation.HandleRsvp;
public sealed class RsvpFlowRulesTests
{
[Fact]
public void Evaluate_ShouldRevertAndAlert_WhenConfirmedSessionGetsDecline()
{
var decision = RsvpFlowRules.Evaluate(
RsvpStatus.Declined,
SessionStatus.Confirmed,
totalParticipants: 3,
confirmedParticipants: 2);
Assert.True(decision.ShouldAlertGm);
Assert.True(decision.ShouldRevertSessionToConfirmationSent);
Assert.False(decision.ShouldMarkSessionConfirmed);
Assert.Equal("Вы отказались от участия.", decision.CallbackText);
}
[Fact]
public void Evaluate_ShouldMarkConfirmed_WhenLastParticipantConfirms()
{
var decision = RsvpFlowRules.Evaluate(
RsvpStatus.Confirmed,
SessionStatus.ConfirmationSent,
totalParticipants: 3,
confirmedParticipants: 3);
Assert.True(decision.ShouldMarkSessionConfirmed);
Assert.True(decision.ShouldNotifyGroup);
Assert.True(decision.ShouldNotifyGm);
}
[Fact]
public void Evaluate_ShouldKeepWaiting_WhenNotEveryoneConfirmed()
{
var decision = RsvpFlowRules.Evaluate(
RsvpStatus.Confirmed,
SessionStatus.ConfirmationSent,
totalParticipants: 4,
confirmedParticipants: 2);
Assert.False(decision.ShouldMarkSessionConfirmed);
Assert.False(decision.ShouldNotifyGroup);
Assert.False(decision.ShouldNotifyGm);
}
}
@@ -0,0 +1,88 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class NewSessionCommandParserTests
{
[Fact]
public void Parse_ShouldExtractTitleLinkAndUpcomingTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Время: 01.05.2026 20:00
Мест: 4
Ссылка: https://example.test/room
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("Curse of Strahd", result.Title);
Assert.Equal("https://example.test/room", result.Link);
Assert.Equal(4, result.MaxPlayers);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
Assert.Empty(result.PastTimeInputs);
Assert.Empty(result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldCollectPastAndInvalidTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
Название: Delta Green
Время: 20.04.2026 19:30
Время: 31.04.2026 19:30
Время: 25.04.2026 18:00
Ссылка: https://example.test/dg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Single(result.ScheduledTimes);
Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs);
Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.Link);
}
[Fact]
public void Parse_ShouldCollectInvalidSeatLimit()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
Мест: 0
Ссылка: https://example.test/blades
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.MaxPlayers);
Assert.Equal(["0"], result.InvalidSeatLimitInputs);
}
}
@@ -0,0 +1,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));
}
}
@@ -0,0 +1,32 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleTimeInputHandlerTests
{
[Fact]
public void BuildVotingMessage_ShouldShowApprovedAndPendingParticipants()
{
var approvedId = Guid.NewGuid();
var pendingId = Guid.NewGuid();
var currentTime = new DateTime(2026, 4, 25, 16, 30, 0, DateTimeKind.Utc);
var newTime = new DateTimeOffset(2026, 4, 26, 17, 0, 0, TimeSpan.Zero);
var participants = new List<VoteParticipantDto>
{
new(approvedId, "Alice", "alice"),
new(pendingId, "Bob", null)
};
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage(
"Shadowrun",
currentTime,
newTime,
participants,
[approvedId]);
Assert.Contains("Shadowrun", text);
Assert.Contains("✅ @alice", text);
Assert.Contains("⏳ Bob", text);
Assert.Contains("Голоса: 1/2 ✅", text);
}
}
@@ -0,0 +1,36 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class RescheduleVoteRulesTests
{
[Fact]
public void Evaluate_ShouldReject_WhenParticipantVotesNo()
{
var decision = RescheduleVoteRules.Evaluate("no", totalParticipants: 4, approvedParticipants: 3);
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
Assert.False(decision.ShouldRescheduleSession);
Assert.Equal("Вы проголосовали против переноса.", decision.CallbackText);
}
[Fact]
public void Evaluate_ShouldApprove_WhenEveryoneVotedYes()
{
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 3, approvedParticipants: 3);
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
Assert.True(decision.ShouldRescheduleSession);
Assert.True(decision.ShouldResetParticipantRsvps);
}
[Fact]
public void Evaluate_ShouldStayPending_WhileVotesOutstanding()
{
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 5, approvedParticipants: 2);
Assert.Equal(RescheduleVoteOutcome.Pending, decision.Outcome);
Assert.False(decision.ShouldRescheduleSession);
Assert.False(decision.ShouldResetParticipantRsvps);
}
}
@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" />
<ProjectReference Include="..\..\src\GmRelay.Web\GmRelay.Web.csproj" />
</ItemGroup>
</Project>
</Project>
@@ -0,0 +1,31 @@
using GmRelay.Bot.Infrastructure.Logging;
namespace GmRelay.Bot.Tests.Infrastructure.Logging;
public sealed class SecretRedactorTests
{
[Fact]
public void RedactConnectionString_ShouldMaskDatabasePassword()
{
var result = SecretRedactor.RedactConnectionString(
"Host=localhost;Port=5432;Database=gmrelay;Username=gmrelay;Password=super-secret");
Assert.Contains("Password=***", result);
Assert.DoesNotContain("super-secret", result);
Assert.Contains("Host=localhost", result);
}
[Fact]
public void RedactText_ShouldMaskKnownSecretKeys()
{
var result = SecretRedactor.RedactText(
"Password=super-secret Token=telegram-token apiKey=service-key");
Assert.DoesNotContain("super-secret", result);
Assert.DoesNotContain("telegram-token", result);
Assert.DoesNotContain("service-key", result);
Assert.Contains("Password=***", result);
Assert.Contains("Token=***", result);
Assert.Contains("apiKey=***", result);
}
}
@@ -0,0 +1,101 @@
using System.Reflection;
using GmRelay.Bot.Infrastructure.Telegram;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
public sealed class TelegramBotServiceTests
{
[Fact]
public async Task ExecuteAsync_ShouldStartPollingAfterLastPendingUpdate()
{
using var cts = new CancellationTokenSource();
var updateSource = new FakeTelegramUpdateSource(cts);
var updateHandler = new FakeTelegramUpdateHandler();
var service = new TelegramBotService(
updateSource,
updateHandler,
NullLogger<TelegramBotService>.Instance);
await InvokeExecuteAsync(service, cts.Token);
Assert.Empty(updateHandler.HandledUpdates);
Assert.Collection(
updateSource.Calls,
call =>
{
Assert.Equal(-1, call.Offset);
Assert.Equal(1, call.Limit);
Assert.Null(call.Timeout);
Assert.Null(call.AllowedUpdates);
},
call =>
{
Assert.Equal(43, call.Offset);
Assert.Null(call.Limit);
Assert.Equal(30, call.Timeout);
Assert.Equal([UpdateType.Message, UpdateType.CallbackQuery], call.AllowedUpdates);
});
}
private static async Task InvokeExecuteAsync(TelegramBotService service, CancellationToken cancellationToken)
{
var executeAsync = typeof(TelegramBotService).GetMethod(
"ExecuteAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(executeAsync);
var task = executeAsync.Invoke(service, [cancellationToken]) as Task;
Assert.NotNull(task);
await task;
}
private sealed class FakeTelegramUpdateHandler : ITelegramUpdateHandler
{
public List<Update> HandledUpdates { get; } = [];
public Task RouteAsync(Update update, CancellationToken ct)
{
HandledUpdates.Add(update);
return Task.CompletedTask;
}
}
private sealed class FakeTelegramUpdateSource(CancellationTokenSource cts) : ITelegramUpdateSource
{
public List<PollCall> Calls { get; } = [];
public Task<Update[]> GetUpdatesAsync(
int offset,
int? limit = null,
int? timeout = null,
IEnumerable<UpdateType>? allowedUpdates = null,
CancellationToken cancellationToken = default)
{
Calls.Add(new PollCall(offset, limit, timeout, allowedUpdates?.ToArray()));
return Calls.Count switch
{
1 => Task.FromResult(new[] { new Update { Id = 42 } }),
2 => ReturnAndCancelAsync(),
_ => throw new InvalidOperationException("Unexpected polling call.")
};
}
private Task<Update[]> ReturnAndCancelAsync()
{
cts.Cancel();
return Task.FromResult(Array.Empty<Update>());
}
}
private sealed record PollCall(
int Offset,
int? Limit,
int? Timeout,
UpdateType[]? AllowedUpdates);
}
@@ -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));
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace GmRelay.Bot.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
@@ -0,0 +1,221 @@
using GmRelay.Web.Services;
namespace GmRelay.Bot.Tests.Web;
public sealed class AuthorizedSessionServiceTests
{
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId);
Assert.NotNull(sessions);
Assert.Single(sessions);
Assert.Equal("Session A", sessions[0].Title);
}
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
]);
var service = new AuthorizedSessionService(store);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L);
Assert.Null(sessions);
}
[Fact]
public async Task GetSessionForGmAsync_ReturnsSession_WhenSessionBelongsToOwnedGroup()
{
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, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var session = await service.GetSessionForGmAsync(sessionId, gmId);
Assert.NotNull(session);
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()
{
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 action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateCalled);
}
[Fact]
public async Task UpdateSessionForGmAsync_UpdatesOwnedSession()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var scheduledAt = DateTime.UtcNow.AddDays(1);
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, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5);
Assert.True(store.UpdateCalled);
Assert.Equal(groupId, store.LastUpdatedGroupId);
Assert.Equal(sessionId, store.LastUpdatedSessionId);
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(
IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null) : ISessionStore
{
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
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());
public Task<WebGameGroup?> GetGroupAsync(Guid groupId)
{
groupsById.TryGetValue(groupId, out var group);
return Task.FromResult(group);
}
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) =>
Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());
public Task<WebSession?> GetSessionAsync(Guid sessionId)
{
sessionsById.TryGetValue(sessionId, out var session);
return Task.FromResult(session);
}
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{
UpdateCalled = true;
LastUpdatedSessionId = sessionId;
LastUpdatedGroupId = groupId;
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();
}
}