9 Commits

Author SHA1 Message Date
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
58 changed files with 2849 additions and 734 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.0.1 VERSION: 1.1.4
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.0.1</Version> <Version>1.1.4</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+2 -2
View File
@@ -18,7 +18,7 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.0.1 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.4
container_name: gmrelay_bot container_name: gmrelay_bot
restart: always restart: always
network_mode: host network_mode: host
@@ -30,7 +30,7 @@ services:
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.0.1 image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.4
container_name: gmrelay_web container_name: gmrelay_web
restart: always restart: always
network_mode: host network_mode: host
@@ -1,14 +1,11 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp; namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRsvpCommand( public sealed record HandleRsvpCommand(
Guid SessionId, Guid SessionId,
long TelegramUserId, long TelegramUserId,
@@ -17,13 +14,12 @@ public sealed record HandleRsvpCommand(
long ChatId, long ChatId,
int MessageId); int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined); internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
internal sealed record SessionContext( internal sealed record SessionContext(
string Title, string Title,
DateTime ScheduledAt, DateTime ScheduledAt,
string Status,
long GmTelegramId, long GmTelegramId,
long TelegramChatId); long TelegramChatId);
@@ -33,21 +29,6 @@ internal sealed record ParticipantRsvp(
string? TelegramUsername, string? TelegramUsername,
string RsvpStatus); string RsvpStatus);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles the "Буду" / "Не смогу" callback query.
///
/// Flow:
/// 1. Validate that the user is a participant in this session
/// 2. Record or update their RSVP (idempotent)
/// 3. If declined → alert GM privately, revert session if was Confirmed
/// 4. If all non-GM players confirmed → mark session Confirmed, notify group + GM
/// 5. Update the inline keyboard to show current RSVP status
///
/// Concurrency: two simultaneous clicks on different rows don't conflict (MVCC).
/// The last EditMessage wins, which is fine — both reflect up-to-date state.
/// </summary>
public sealed class HandleRsvpHandler( public sealed class HandleRsvpHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
@@ -58,12 +39,11 @@ public sealed class HandleRsvpHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
// ── 1. Validate participant ──────────────────────────────────
var participantExists = await connection.ExecuteScalarAsync<bool>( var participantExists = await connection.ExecuteScalarAsync<bool>(
""" """
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 FROM session_participants sp SELECT 1
FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId AND p.telegram_id = @TelegramUserId
@@ -82,8 +62,6 @@ public sealed class HandleRsvpHandler(
return; return;
} }
// ── 2. Record RSVP (idempotent) ─────────────────────────────
var updated = await connection.ExecuteAsync( var updated = await connection.ExecuteAsync(
""" """
UPDATE session_participants UPDATE session_participants
@@ -98,7 +76,6 @@ public sealed class HandleRsvpHandler(
if (updated == 0) if (updated == 0)
{ {
// Already in this state — just dismiss the loading spinner
var alreadyText = command.Status == RsvpStatus.Confirmed var alreadyText = command.Status == RsvpStatus.Confirmed
? "Вы уже подтвердили участие." ? "Вы уже подтвердили участие."
: "Вы уже отказались от участия."; : "Вы уже отказались от участия.";
@@ -110,11 +87,11 @@ public sealed class HandleRsvpHandler(
return; return;
} }
// ── 3. Load session context ─────────────────────────────────
var session = await connection.QuerySingleAsync<SessionContext>( var session = await connection.QuerySingleAsync<SessionContext>(
""" """
SELECT s.title, s.scheduled_at AS ScheduledAt, SELECT s.title,
s.scheduled_at AS ScheduledAt,
s.status AS Status,
g.gm_telegram_id AS GmTelegramId, g.gm_telegram_id AS GmTelegramId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId
FROM sessions s FROM sessions s
@@ -124,11 +101,12 @@ public sealed class HandleRsvpHandler(
new { command.SessionId }, new { command.SessionId },
transaction); transaction);
// ── 4. Handle decline ───────────────────────────────────────
if (command.Status == RsvpStatus.Declined) if (command.Status == RsvpStatus.Declined)
{ {
// Revert session to ConfirmationSent if it was Confirmed var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
if (decision.ShouldRevertSessionToConfirmationSent)
{
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE sessions UPDATE sessions
@@ -142,8 +120,8 @@ public sealed class HandleRsvpHandler(
Confirmed = SessionStatus.Confirmed Confirmed = SessionStatus.Confirmed
}, },
transaction); transaction);
}
// Alert GM immediately via private message
var declinedPlayer = await connection.QuerySingleAsync<string>( var declinedPlayer = await connection.QuerySingleAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId", "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId }, new { command.TelegramUserId },
@@ -151,7 +129,6 @@ public sealed class HandleRsvpHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// Send alert outside transaction (network call)
try try
{ {
await bot.SendMessage( await bot.SendMessage(
@@ -161,16 +138,14 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
command.SessionId);
} }
await bot.AnswerCallbackQuery( await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId, callbackQueryId: command.CallbackQueryId,
text: "Вы отказались от участия.", text: decision.CallbackText,
cancellationToken: ct); cancellationToken: ct);
} }
// ── 5. Handle confirm — check if ALL confirmed ──────────────
else else
{ {
var counts = await connection.QuerySingleAsync<RsvpCounts>( var counts = await connection.QuerySingleAsync<RsvpCounts>(
@@ -190,9 +165,9 @@ public sealed class HandleRsvpHandler(
}, },
transaction); transaction);
var allConfirmed = counts.Confirmed == counts.Total; var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
if (allConfirmed) if (decision.ShouldMarkSessionConfirmed)
{ {
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
@@ -206,9 +181,8 @@ public sealed class HandleRsvpHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
if (allConfirmed) if (decision.ShouldNotifyGroup)
{ {
// Notify group
try try
{ {
await bot.SendMessage( await bot.SendMessage(
@@ -218,11 +192,12 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
command.SessionId); }
} }
// Notify GM privately if (decision.ShouldNotifyGm)
{
try try
{ {
await bot.SendMessage( await bot.SendMessage(
@@ -232,27 +207,20 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
command.SessionId);
} }
} }
await bot.AnswerCallbackQuery( await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId, callbackQueryId: command.CallbackQueryId,
text: "Вы подтвердили участие!", text: decision.CallbackText,
cancellationToken: ct); cancellationToken: ct);
} }
// ── 6. Update inline keyboard message ───────────────────────
await UpdateConfirmationMessage(command, session, ct); await UpdateConfirmationMessage(command, session, ct);
} }
/// <summary> private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
/// Re-renders the confirmation message with current RSVP statuses.
/// </summary>
private async Task UpdateConfirmationMessage(
HandleRsvpCommand command, SessionContext session, CancellationToken ct)
{ {
try try
{ {
@@ -279,34 +247,47 @@ public sealed class HandleRsvpHandler(
{ {
$"🎲 Подтвердите участие в «{session.Title}»", $"🎲 Подтвердите участие в «{session.Title}»",
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)", $"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
"" string.Empty
}; };
foreach (var p in confirmed) foreach (var participant in confirmed)
lines.Add($" ✅ {FormatName(p)}"); {
foreach (var p in declined) lines.Add($" ✅ {FormatName(participant)}");
lines.Add($" ❌ ~~{FormatName(p)}~~"); }
foreach (var p in pending)
lines.Add($" ⏳ {FormatName(p)}");
lines.Add(""); foreach (var participant in declined)
{
lines.Add($" ❌ ~~{FormatName(participant)}~~");
}
foreach (var participant in pending)
{
lines.Add($" ⏳ {FormatName(participant)}");
}
lines.Add(string.Empty);
if (confirmed.Count == participants.Count) if (confirmed.Count == participants.Count)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})"); lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
}
else if (declined.Count > 0) else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)"); lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
}
else else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})"); lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
}
var text = string.Join("\n", lines); var text = string.Join("\n", lines);
// Keep buttons unless everyone confirmed
var replyMarkup = confirmed.Count == participants.Count var replyMarkup = confirmed.Count == participants.Count
? null ? null
: new InlineKeyboardMarkup([ : new InlineKeyboardMarkup([
[ [
InlineKeyboardButton.WithCallbackData("\u2705 Буду", $"rsvp:confirm:{command.SessionId}"), InlineKeyboardButton.WithCallbackData(" Буду", $"rsvp:confirm:{command.SessionId}"),
InlineKeyboardButton.WithCallbackData("\u274c Не смогу", $"rsvp:decline:{command.SessionId}") InlineKeyboardButton.WithCallbackData(" Не смогу", $"rsvp:decline:{command.SessionId}")
] ]
]); ]);
@@ -319,12 +300,10 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
// EditMessage can fail if message is too old or unchanged — non-critical logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}",
command.SessionId);
} }
} }
private static string FormatName(ParticipantRsvp p) => private static string FormatName(ParticipantRsvp participant) =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName; participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName;
} }
@@ -0,0 +1,42 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
internal sealed record RsvpFlowDecision(
string CallbackText,
bool ShouldAlertGm,
bool ShouldRevertSessionToConfirmationSent,
bool ShouldMarkSessionConfirmed,
bool ShouldNotifyGroup,
bool ShouldNotifyGm);
internal static class RsvpFlowRules
{
public static RsvpFlowDecision Evaluate(
string requestedStatus,
string currentSessionStatus,
int totalParticipants,
int confirmedParticipants)
{
if (requestedStatus == RsvpStatus.Declined)
{
return new RsvpFlowDecision(
CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.",
ShouldAlertGm: true,
ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed,
ShouldMarkSessionConfirmed: false,
ShouldNotifyGroup: false,
ShouldNotifyGm: false);
}
var everyoneConfirmed = confirmedParticipants == totalParticipants;
return new RsvpFlowDecision(
CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!",
ShouldAlertGm: false,
ShouldRevertSessionToConfirmationSent: false,
ShouldMarkSessionConfirmed: everyoneConfirmed,
ShouldNotifyGroup: everyoneConfirmed,
ShouldNotifyGm: everyoneConfirmed);
}
}
@@ -48,7 +48,10 @@ public sealed class CancelSessionHandler(
} }
// 2. Отменяем сессию // 2. Отменяем сессию
await connection.ExecuteAsync("UPDATE sessions SET status = 'Cancelled' WHERE id = @Id", new { Id = command.SessionId }, transaction); await connection.ExecuteAsync(
"UPDATE sessions SET status = @Status WHERE id = @Id",
new { Id = command.SessionId, Status = SessionStatus.Cancelled },
transaction);
// 3. Загружаем весь батч для перерисовки // 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@@ -1,11 +1,9 @@
using System.Text.RegularExpressions;
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -16,37 +14,25 @@ public sealed class CreateSessionHandler(
{ {
public async Task HandleAsync(Message message, CancellationToken cancellationToken) public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{ {
var text = message.Text ?? ""; var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
string? title = null; foreach (var timeInput in parseResult.PastTimeInputs)
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
foreach (var line in text.Split('\n'))
{ {
var trimmed = line.Trim(); await botClient.SendMessage(
if (trimmed.StartsWith("Название:", StringComparison.OrdinalIgnoreCase)) message.Chat.Id,
title = trimmed["Название:".Length..].Trim(); $"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
else if (trimmed.StartsWith("Ссылка:", StringComparison.OrdinalIgnoreCase)) cancellationToken: cancellationToken);
link = trimmed["Ссылка:".Length..].Trim();
else if (trimmed.StartsWith("Время:", StringComparison.OrdinalIgnoreCase))
{
var timeStr = trimmed["Время:".Length..].Trim();
if (MoscowTime.TryParseMoscow(timeStr, out var scheduledAt))
{
if (scheduledAt > DateTimeOffset.UtcNow)
scheduledTimes.Add(scheduledAt);
else
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Дата {timeStr} находится в прошлом и будет пропущена.", cancellationToken: cancellationToken);
}
else
{
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Некорректный формат времени '{timeStr}'. Пропущено.", cancellationToken: cancellationToken);
}
}
} }
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(link) || scheduledTimes.Count == 0) foreach (var timeInput in parseResult.InvalidTimeInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid)
{ {
await botClient.SendMessage( await botClient.SendMessage(
chatId: message.Chat.Id, chatId: message.Chat.Id,
@@ -55,8 +41,10 @@ public sealed class CreateSessionHandler(
return; return;
} }
var title = parseResult.Title!;
var link = parseResult.Link!;
var gmId = message.From!.Id; var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"); var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username; var gmUsername = message.From.Username;
var chatId = message.Chat.Id; var chatId = message.Chat.Id;
@@ -67,20 +55,24 @@ public sealed class CreateSessionHandler(
try try
{ {
// 1. Убеждаемся, что GM зарегистрирован
await connection.ExecuteAsync( await connection.ExecuteAsync(
@"INSERT INTO players (telegram_id, display_name, telegram_username) """
INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TgId, @Name, @Username) VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username;", ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username;
""",
new { TgId = gmId, Name = gmName, Username = gmUsername }, new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction); transaction);
// 2. Убеждаемся, что Группа зарегистрирована
var groupId = await connection.ExecuteScalarAsync<Guid>( var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id) """
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId) VALUES (@ChatId, @ChatName, @GmId)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id;", RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId }, new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction); transaction);
@@ -94,29 +86,37 @@ public sealed class CreateSessionHandler(
messageThreadId = topic.MessageThreadId; messageThreadId = topic.MessageThreadId;
} }
// 3. Создаем сессии в цикле с общим batch_id
var batchId = Guid.NewGuid(); var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>(); var sessions = new List<SessionBatchDto>();
foreach (var dt in scheduledTimes.OrderBy(d => d)) foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
{ {
var sessionId = await connection.ExecuteScalarAsync<Guid>( var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id) """
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId) INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
RETURNING id;", VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId)
new { BatchId = batchId, GroupId = groupId, Title = title, Link = link, ScheduledAt = dt, ThreadId = messageThreadId }, RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
Status = SessionStatus.Planned
},
transaction); transaction);
sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned")); sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned));
} }
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId); logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
// 4. Отправляем сообщение в чат
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>()); var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await botClient.SendMessage( var batchMessage = await botClient.SendMessage(
chatId: chatId, chatId: chatId,
messageThreadId: messageThreadId, messageThreadId: messageThreadId,
@@ -125,12 +125,10 @@ public sealed class CreateSessionHandler(
replyMarkup: renderResult.Markup, replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
// 4b. Сохраняем message_id батч-сообщения для дальнейшего редактирования
await connection.ExecuteAsync( await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId", "UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId }); new { MsgId = batchMessage.MessageId, BatchId = batchId });
// 5. Удаляем исходное сообщение с командой /newsession, чтобы не спамить
try try
{ {
await botClient.DeleteMessage( await botClient.DeleteMessage(
@@ -0,0 +1,69 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult(
string? Title,
string? Link,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.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:";
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = 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;
}
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, scheduledTimes, pastTimeInputs, invalidTimeInputs);
}
}
@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
@@ -21,10 +22,10 @@ public sealed class ExportCalendarHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId WHERE g.telegram_chat_id = @ChatId
AND s.status = 'Planned' AND s.status = @Planned
AND s.scheduled_at > NOW() AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id }); new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -80,10 +80,10 @@ public sealed class DeleteSessionHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = command.ChatId }); new { ChatId = command.ChatId, Cancelled = SessionStatus.Cancelled });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -23,10 +23,10 @@ public sealed class ListSessionsHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id }); new { ChatId = message.Chat.Id, Cancelled = SessionStatus.Cancelled });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -154,10 +154,10 @@ public sealed class HandleRescheduleTimeInputHandler(
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE sessions SET scheduled_at = @NewTime, status = 'Planned', updated_at = now() UPDATE sessions SET scheduled_at = @NewTime, status = @Status, updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId }, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -1,25 +1,20 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRescheduleVoteCommand( public sealed record HandleRescheduleVoteCommand(
Guid ProposalId, Guid ProposalId,
string Vote, // "yes" or "no" string Vote,
long TelegramUserId, long TelegramUserId,
string CallbackQueryId, string CallbackQueryId,
long ChatId, long ChatId,
int MessageId); int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record VoteProposalDto( internal sealed record VoteProposalDto(
Guid Id, Guid Id,
Guid SessionId, Guid SessionId,
@@ -32,17 +27,6 @@ internal sealed record VoteProposalDto(
int? ConfirmationMessageId, int? ConfirmationMessageId,
int? BatchMessageId); int? BatchMessageId);
internal sealed record VoteCountDto(int Total, int Approved);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles "✅ Согласен" / "❌ Против" votes on a reschedule proposal.
///
/// If anyone votes no → proposal rejected, old time stays.
/// If all vote yes → session time updated, batch message re-rendered,
/// session status reset to Planned so confirmation triggers work correctly.
/// </summary>
public sealed class HandleRescheduleVoteHandler( public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
@@ -53,12 +37,15 @@ public sealed class HandleRescheduleVoteHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Load proposal + session info
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>( var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
""" """
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.proposed_at AS ProposedAt, SELECT rp.id AS Id,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt, rp.session_id AS SessionId,
s.batch_id AS BatchId, s.status AS SessionStatus, rp.proposed_at AS ProposedAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
s.status AS SessionStatus,
s.confirmation_message_id AS ConfirmationMessageId, s.confirmation_message_id AS ConfirmationMessageId,
s.batch_message_id AS BatchMessageId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId
@@ -72,12 +59,13 @@ public sealed class HandleRescheduleVoteHandler(
if (proposal is null) if (proposal is null)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(
"Голосование уже завершено или не найдено.", cancellationToken: ct); command.CallbackQueryId,
"Голосование уже завершено или не найдено.",
cancellationToken: ct);
return; return;
} }
// 2. Verify voter is a participant of this session
var playerId = await connection.ExecuteScalarAsync<Guid?>( var playerId = await connection.ExecuteScalarAsync<Guid?>(
""" """
SELECT p.id SELECT p.id
@@ -92,23 +80,52 @@ public sealed class HandleRescheduleVoteHandler(
if (playerId is null) if (playerId is null)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(
"Вы не являетесь участником этой сессии.", cancellationToken: ct); command.CallbackQueryId,
"Вы не являетесь участником этой сессии.",
cancellationToken: ct);
return; return;
} }
// 3. Record vote (upsert) await connection.ExecuteAsync(
var inserted = await connection.ExecuteAsync(
""" """
INSERT INTO reschedule_votes (proposal_id, player_id, vote) INSERT INTO reschedule_votes (proposal_id, player_id, vote)
VALUES (@ProposalId, @PlayerId, @Vote) VALUES (@ProposalId, @PlayerId, @Vote)
ON CONFLICT (proposal_id, player_id) DO UPDATE SET vote = EXCLUDED.vote, voted_at = now() ON CONFLICT (proposal_id, player_id) DO UPDATE
SET vote = EXCLUDED.vote,
voted_at = now()
""", """,
new { command.ProposalId, PlayerId = playerId.Value, command.Vote }, new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
transaction); transaction);
// 4. Handle "no" vote — immediately reject var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
if (command.Vote == "no") ? new List<VoteParticipantDto>()
: (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
""",
new { proposal.SessionId },
transaction)).ToList();
var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
? new HashSet<Guid>()
: (await connection.QueryAsync<Guid>(
"""
SELECT player_id
FROM reschedule_votes
WHERE proposal_id = @ProposalId AND vote = 'yes'
""",
new { command.ProposalId },
transaction)).ToHashSet();
var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count);
if (decision.Outcome == RescheduleVoteOutcome.Rejected)
{ {
await connection.ExecuteAsync( await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id", "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
@@ -117,12 +134,10 @@ public sealed class HandleRescheduleVoteHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// Get voter's name
var voterName = await connection.QuerySingleOrDefaultAsync<string>( var voterName = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TgId", "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { TgId = command.TelegramUserId }); new { command.TelegramUserId });
// Update voting message — show rejection
try try
{ {
await bot.EditMessageText( await bot.EditMessageText(
@@ -137,49 +152,26 @@ public sealed class HandleRescheduleVoteHandler(
logger.LogWarning(ex, "Failed to update vote message after rejection"); logger.LogWarning(ex, "Failed to update vote message after rejection");
} }
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы проголосовали против переноса.", cancellationToken: ct); await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId); logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
return; return;
} }
// 5. Handle "yes" vote — check if all approved if (decision.ShouldRescheduleSession)
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
""",
new { proposal.SessionId },
transaction)).ToList();
var approvedPlayerIds = (await connection.QueryAsync<Guid>(
"""
SELECT player_id FROM reschedule_votes
WHERE proposal_id = @ProposalId AND vote = 'yes'
""",
new { command.ProposalId },
transaction)).ToHashSet();
var allApproved = approvedPlayerIds.Count == participants.Count;
if (allApproved)
{ {
// 6. All approved — reschedule! var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); // ProposedAt is stored in UTC
// Update session time and reset status to Planned for fresh notification cycle
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE sessions UPDATE sessions
SET scheduled_at = @NewTime, SET scheduled_at = @NewTime,
status = 'Planned', status = @Status,
confirmation_message_id = NULL, confirmation_message_id = NULL,
link_message_id = NULL, link_message_id = NULL,
updated_at = now() updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId }, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -187,19 +179,21 @@ public sealed class HandleRescheduleVoteHandler(
new { Id = command.ProposalId }, new { Id = command.ProposalId },
transaction); transaction);
// Reset all participant RSVP to Pending for the new confirmation cycle if (decision.ShouldResetParticipantRsvps)
{
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE session_participants UPDATE session_participants
SET rsvp_status = 'Pending', responded_at = NULL SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId AND is_gm = false WHERE session_id = @SessionId AND is_gm = false
""", """,
new { proposal.SessionId }, new { proposal.SessionId },
transaction); transaction);
}
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// Update voting message — show approval
try try
{ {
await bot.EditMessageText( await bot.EditMessageText(
@@ -214,21 +208,24 @@ public sealed class HandleRescheduleVoteHandler(
logger.LogWarning(ex, "Failed to update vote message after approval"); logger.LogWarning(ex, "Failed to update vote message after approval");
} }
// Re-render batch message
await TryUpdateBatchMessage(proposal, ct); await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation("Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})", logger.LogInformation(
proposal.SessionId, newTime, command.ProposalId); "Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
proposal.SessionId,
newTime,
command.ProposalId);
} }
else else
{ {
// Not all voted yet — update the voting message to show progress
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage( var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title, proposal.CurrentScheduledAt, proposal.Title,
proposal.CurrentScheduledAt,
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero), new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
participants, approvedPlayerIds); participants,
approvedPlayerIds);
var keyboard = new InlineKeyboardMarkup([ var keyboard = new InlineKeyboardMarkup([
[ [
@@ -253,15 +250,9 @@ public sealed class HandleRescheduleVoteHandler(
} }
} }
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
allApproved ? "Вы подтвердили перенос! Все согласны — время обновлено." : "Вы подтвердили перенос!",
cancellationToken: ct);
} }
/// <summary>
/// Re-renders the batch schedule message to reflect the updated session time.
/// If batch_message_id is stored, edits the original message. Otherwise sends a notification.
/// </summary>
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct) private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
{ {
try try
@@ -274,7 +265,9 @@ public sealed class HandleRescheduleVoteHandler(
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
""" """
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM session_participants sp FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id JOIN sessions s ON sp.session_id = s.id
@@ -285,7 +278,6 @@ public sealed class HandleRescheduleVoteHandler(
if (proposal.BatchMessageId.HasValue) if (proposal.BatchMessageId.HasValue)
{ {
// Edit the original batch schedule message in-place
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants); var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText( await bot.EditMessageText(
@@ -298,10 +290,9 @@ public sealed class HandleRescheduleVoteHandler(
} }
else else
{ {
// Fallback for sessions created before V005 migration (no batch_message_id)
await bot.SendMessage( await bot.SendMessage(
chatId: proposal.TelegramChatId, chatId: proposal.TelegramChatId,
text: $"📢 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).", text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct); cancellationToken: ct);
} }
@@ -1,4 +1,5 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -39,9 +40,9 @@ public sealed class InitiateRescheduleHandler(
SELECT s.title AS Title, g.gm_telegram_id AS GmId SELECT s.title AS Title, g.gm_telegram_id AS GmId
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId AND s.status != 'Cancelled' WHERE s.id = @SessionId AND s.status != @Cancelled
""", """,
new { command.SessionId }); new { command.SessionId, Cancelled = SessionStatus.Cancelled });
if (session is null) if (session is null)
{ {
@@ -0,0 +1,39 @@
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal enum RescheduleVoteOutcome
{
Pending,
Rejected,
Approved
}
internal sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome,
string CallbackText,
bool ShouldRescheduleSession,
bool ShouldResetParticipantRsvps);
internal static class RescheduleVoteRules
{
public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants)
{
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
{
return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected,
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.",
ShouldRescheduleSession: false,
ShouldResetParticipantRsvps: false);
}
var everyoneApproved = approvedParticipants == totalParticipants;
return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
CallbackText: everyoneApproved
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
ShouldRescheduleSession: everyoneApproved,
ShouldResetParticipantRsvps: everyoneApproved);
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
</ItemGroup> </ItemGroup>
<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" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" /> <PackageReference Include="Dapper.AOT" Version="1.0.48" />
<PackageReference Include="dbup-postgresql" Version="7.0.1" /> <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;
using Telegram.Bot.Types.Enums; 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. /// Stateless — all state is in PostgreSQL. Safe to restart at any time.
/// </summary> /// </summary>
public sealed class TelegramBotService( public sealed class TelegramBotService(
ITelegramBotClient bot, ITelegramUpdateSource updateSource,
UpdateRouter router, ITelegramUpdateHandler updateHandler,
ILogger<TelegramBotService> logger) : BackgroundService ILogger<TelegramBotService> logger) : BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
logger.LogInformation("Telegram bot polling started"); logger.LogInformation("Telegram bot polling started");
// Skip any pending updates from before this startup var offset = await GetStartupOffsetAsync(stoppingToken);
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;
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
try try
{ {
var updates = await bot.GetUpdates( var updates = await updateSource.GetUpdatesAsync(
offset: offset, offset: offset,
timeout: 30, timeout: 30,
allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery], allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
@@ -47,7 +32,7 @@ public sealed class TelegramBotService(
{ {
try try
{ {
await router.RouteAsync(update, stoppingToken); await updateHandler.RouteAsync(update, stoppingToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -70,4 +55,33 @@ public sealed class TelegramBotService(
logger.LogInformation("Telegram bot polling stopped"); 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);
}
@@ -28,7 +28,7 @@ public sealed class UpdateRouter(
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler, HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler, HandleRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot, ITelegramBotClient bot,
ILogger<UpdateRouter> logger) ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{ {
public async Task RouteAsync(Update update, CancellationToken ct) public async Task RouteAsync(Update update, CancellationToken ct)
{ {
+9 -1
View File
@@ -4,6 +4,7 @@ using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database; using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Bot.Infrastructure.Logging;
using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
using Npgsql; using Npgsql;
@@ -20,11 +21,16 @@ builder.AddServiceDefaults();
builder.Services.AddSingleton<NpgsqlDataSource>(sp => builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
{ {
var config = sp.GetRequiredService<IConfiguration>(); var config = sp.GetRequiredService<IConfiguration>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var connectionString = config.GetConnectionString("gmrelaydb") var connectionString = config.GetConnectionString("gmrelaydb")
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb."); "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); 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."); "Telegram:BotToken is required. Set via environment variable Telegram__BotToken or appsettings.json.");
return new TelegramBotClient(token); return new TelegramBotClient(token);
}); });
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
// ── Feature handlers (explicit registration — AOT safe) ────────────── // ── Feature handlers (explicit registration — AOT safe) ──────────────
builder.Services.AddSingleton<SendConfirmationHandler>(); builder.Services.AddSingleton<SendConfirmationHandler>();
@@ -57,6 +64,7 @@ builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
// ── Telegram infrastructure ────────────────────────────────────────── // ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>(); builder.Services.AddSingleton<UpdateRouter>();
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
builder.Services.AddHostedService<TelegramBotService>(); builder.Services.AddHostedService<TelegramBotService>();
// ── Session scheduler ──────────────────────────────────────────────── // ── Session scheduler ────────────────────────────────────────────────
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
@@ -1,3 +1,5 @@
using System.Collections.Frozen;
namespace GmRelay.Shared.Domain; namespace GmRelay.Shared.Domain;
public static class SessionStatus public static class SessionStatus
@@ -6,4 +8,13 @@ public static class SessionStatus
public const string ConfirmationSent = "ConfirmationSent"; public const string ConfirmationSent = "ConfirmationSent";
public const string Confirmed = "Confirmed"; public const string Confirmed = "Confirmed";
public const string Cancelled = "Cancelled"; public const string Cancelled = "Cancelled";
public static IReadOnlySet<string> All { get; } =
new[] { Planned, ConfirmationSent, Confirmed, Cancelled }
.ToFrozenSet(StringComparer.Ordinal);
public static bool IsKnown(string status) => All.Contains(status);
public static bool IsCancelled(string status) =>
string.Equals(status, Cancelled, StringComparison.Ordinal);
} }
@@ -36,14 +36,10 @@ public static class SessionBatchRenderer
messageText += " <i>Пока никто не записался</i>\n"; messageText += " <i>Пока никто не записался</i>\n";
} }
if (session.Status == "Cancelled") if (SessionStatus.IsCancelled(session.Status))
{ {
messageText += "❌ <i>Сессия отменена</i>\n\n"; messageText += "❌ <i>Сессия отменена</i>\n\n";
} }
else if (session.Status == "RecruitmentClosed")
{
messageText += "🔒 <i>Набор завершен</i>\n\n";
}
else else
{ {
messageText += "\n"; messageText += "\n";
+22 -3
View File
@@ -1,12 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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="/" /> <base href="/" />
<ResourcePreloader /> <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["app.css"]" />
<link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" /> <link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" />
<ImportMap /> <ImportMap />
@@ -18,6 +22,21 @@
<Routes @rendermode="InteractiveServer" /> <Routes @rendermode="InteractiveServer" />
<ReconnectModal /> <ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script> <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> </body>
</html> </html>
@@ -1,19 +1,15 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
<div class="page"> <div class="page">
<div class="sidebar"> <aside class="sidebar">
<NavMenu /> <NavMenu />
</div> </aside>
<main> <div class="main-area">
<div class="top-row px-4"> <article class="content">
<a href="https://github.com/Toutsu/GmRelayBot" target="_blank">О проекте</a>
</div>
<article class="content px-4">
@Body @Body
</article> </article>
</main> </div>
</div> </div>
<div id="blazor-error-ui" data-nosnippet> <div id="blazor-error-ui" data-nosnippet>
@@ -1,86 +1,39 @@
.page { .page {
position: relative; display: flex;
min-height: 100vh;
}
.sidebar {
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; display: flex;
flex-direction: column; flex-direction: column;
transition: transform var(--transition-smooth);
} }
main { .main-area {
flex: 1; flex: 1;
margin-left: var(--sidebar-width);
min-height: 100vh;
} }
.sidebar { .content {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); padding: 1.5rem 2rem;
} max-width: 100%;
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.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;
}
}
@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;
}
} }
/* === Error UI === */
#blazor-error-ui { #blazor-error-ui {
color-scheme: light only; background: var(--bg-secondary);
background: lightyellow; border-top: 1px solid var(--border-color);
bottom: 0; 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; box-sizing: border-box;
display: none; display: none;
left: 0; left: 0;
@@ -88,6 +41,13 @@ main {
position: fixed; position: fixed;
width: 100%; width: 100%;
z-index: 1000; 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 { #blazor-error-ui .dismiss {
@@ -96,3 +56,29 @@ main {
right: 0.75rem; right: 0.75rem;
top: 0.5rem; 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;
}
}
+53 -21
View File
@@ -1,41 +1,73 @@
<div class="top-row ps-3 navbar navbar-dark"> @inject NavigationManager Navigation
<div class="container-fluid">
<a class="navbar-brand" href="">GM-Relay Web</a> <div class="nav-header">
</div> <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> </div>
<input type="checkbox" title="Навигационное меню" class="navbar-toggler" /> <nav class="nav-body @(isOpen ? "open" : "")">
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
<div class="nav-item px-3"> <div class="nav-section">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All"> <NavLink class="nav-item" href="" Match="NavLinkMatch.All" @onclick="CloseMenu">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Панель управления <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> </NavLink>
</div> </div>
<div class="nav-item px-3 mt-auto">
<div class="nav-link text-light"> <div class="nav-footer">
<span class="bi bi-person-fill" aria-hidden="true"></span> @context.User.Identity?.Name <div class="nav-user">
<div class="nav-user-avatar">
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
</div> </div>
<span class="nav-user-name">@context.User.Identity?.Name</span>
</div> </div>
<div class="nav-item px-3">
<form action="/auth/logout" method="post"> <form action="/auth/logout" method="post">
<AntiforgeryToken /> <AntiforgeryToken />
<button type="submit" class="nav-link btn btn-link text-light text-start w-100 p-0 shadow-none border-0"> <button type="submit" class="nav-logout-btn">
<span class="bi bi-box-arrow-right" aria-hidden="true"></span> Выйти <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> </button>
</form> </form>
<div class="nav-version">v1.1.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<div class="nav-item px-3"> <div class="nav-section">
<NavLink class="nav-link" href="login"> <NavLink class="nav-item" href="login" @onclick="CloseMenu">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Войти <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> </NavLink>
</div> </div>
</NotAuthorized> </NotAuthorized>
</AuthorizeView> </AuthorizeView>
</nav> </nav>
</div>
@code {
private bool isOpen;
private void ToggleMenu() => isOpen = !isOpen;
private void CloseMenu() => isOpen = false;
}
@@ -1,105 +1,194 @@
.navbar-toggler { /* === Nav Header === */
appearance: none; .nav-header {
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);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.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-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex; display: flex;
align-items: center; align-items: center;
line-height: 3rem; justify-content: space-between;
width: 100%; padding: 1.25rem 1rem;
border-bottom: 1px solid var(--border-color);
} }
.nav-item ::deep a.active { .nav-brand {
background-color: rgba(255,255,255,0.37); display: flex;
color: white; align-items: center;
gap: 0.625rem;
text-decoration: none;
color: var(--text-primary);
} }
.nav-item ::deep .nav-link:hover { .nav-brand-icon {
background-color: rgba(255,255,255,0.1); font-size: 1.5rem;
color: white;
} }
.nav-scrollable { .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; display: none;
background: none;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
padding: 0.375rem;
cursor: pointer;
transition: all var(--transition-fast);
} }
.navbar-toggler:checked ~ .nav-scrollable { .nav-toggle:hover {
display: block; background: var(--bg-surface);
color: var(--text-primary);
} }
@media (min-width: 641px) { /* === Nav Body === */
.navbar-toggler { .nav-body {
display: none; flex: 1;
} display: flex;
flex-direction: column;
.nav-scrollable { padding: 0.75rem 0;
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto; overflow-y: auto;
} }
.nav-section {
padding: 0 0.75rem;
flex: 1;
}
/* === Nav Items === */
.nav-item {
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:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.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-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;
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-body {
display: none;
}
.nav-body.open {
display: flex;
}
.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,71 @@
@using GmRelay.Web.Services @using GmRelay.Web.Services
@using GmRelay.Shared.Domain @using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize] @attribute [Authorize]
@inject SessionService SessionService @inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation @inject NavigationManager Navigation
<PageTitle>Редактирование сессии - GM-Relay</PageTitle> <PageTitle>Редактирование сессии GM-Relay</PageTitle>
<div class="container mt-4"> <div class="page-container">
<nav aria-label="breadcrumb"> <ul class="gm-breadcrumb animate-fade-in">
<ol class="breadcrumb"> <li><a href="/">Главная</a></li>
<li class="breadcrumb-item"><a href="/">Главная</a></li> <li class="active">Редактирование сессии</li>
<li class="breadcrumb-item active">Редактирование сессии</li> </ul>
</ol>
</nav>
<h2>Редактирование сессии</h2> <div class="page-header animate-fade-in">
<h2>✏️ Редактирование сессии</h2>
</div>
@if (session == null) @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 else
{ {
<div class="card shadow-sm mt-4"> <div class="glass-card animate-slide-up" style="max-width: 640px;">
<div class="card-body">
<EditForm Model="@model" OnValidSubmit="HandleSubmit"> <EditForm Model="@model" OnValidSubmit="HandleSubmit">
<div class="mb-3"> <div class="gm-form-group">
<label class="form-label font-weight-bold">Название игры</label> <label class="gm-form-label">Название игры</label>
<InputText @bind-Value="model.Title" class="form-control" placeholder="например, D&D 5e: Dragon's Hoard" /> <InputText @bind-Value="model.Title" class="gm-form-control" placeholder="например, D&D 5e: Dragon's Hoard" />
<div class="form-text">Изменение этого поля обновит все сессии в одной группе.</div> <div class="gm-form-hint">Изменение этого поля обновит все сессии в одной группе.</div>
</div> </div>
<div class="mb-3"> <div class="gm-form-group">
<label class="form-label font-weight-bold">Запланированное время (МСК UTC+3)</label> <label class="gm-form-label">Запланированное время (МСК, UTC+3)</label>
<input type="datetime-local" @bind="model.ScheduledAtLocal" class="form-control" /> <input type="datetime-local" @bind="model.ScheduledAtLocal" class="gm-form-control" />
<div class="form-text">Текущее: @session.ScheduledAt.FormatMoscow()</div> <div class="gm-form-hint">Текущее: @session.ScheduledAt.FormatMoscow()</div>
</div> </div>
<div class="mb-3"> <div class="gm-form-group">
<label class="form-label font-weight-bold">Ссылка для подключения</label> <label class="gm-form-label">Ссылка для подключения</label>
<InputText @bind-Value="model.JoinLink" class="form-control" placeholder="Ссылка на Discord или VTT" /> <InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
</div> </div>
<div class="mt-4"> <div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-success" disabled="@isSubmitting"> <button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
@(isSubmitting ? "Сохранение..." : "Сохранить изменения") @(isSubmitting ? "Сохранение..." : "Сохранить изменения")
</button>
<button type="button" class="btn-gm btn-gm-outline" @onclick="GoBack">
Отмена
</button> </button>
<button type="button" class="btn btn-outline-secondary ms-2" @onclick="GoBack">Отмена</button>
</div> </div>
</EditForm> </EditForm>
</div> </div>
</div>
@if (!string.IsNullOrEmpty(errorMessage)) @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> </div>
@@ -65,20 +75,29 @@
[Parameter] public Guid SessionId { get; set; } [Parameter] public Guid SessionId { get; set; }
private WebSession? session; private WebSession? session;
private SessionEditModel model = new(); private SessionEditModel model = new();
private bool isSubmitting = false; private bool isSubmitting;
private string? errorMessage; private string? errorMessage;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
session = await SessionService.GetSessionAsync(SessionId); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (session != null) if (!authState.User.TryGetTelegramId(out var telegramId))
{ {
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.Title = session.Title;
// Convert UTC to Moscow for the picker
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow(); model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink; model.JoinLink = session.JoinLink;
} }
}
private async Task HandleSubmit() private async Task HandleSubmit()
{ {
@@ -87,13 +106,22 @@
try try
{ {
// The value from <input type="datetime-local"> is considered as "unspecified" or local to browser. var authState = await AuthStateProvider.GetAuthenticationStateAsync();
// We treat it as Moscow time (UTC+3) and convert to UTC. if (!authState.User.TryGetTelegramId(out var telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; 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);
Navigation.NavigateTo($"/group/{session!.GroupId}"); Navigation.NavigateTo($"/group/{session!.GroupId}");
} }
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex) catch (Exception ex)
{ {
errorMessage = "Не удалось сохранить изменения: " + ex.Message; errorMessage = "Не удалось сохранить изменения: " + ex.Message;
+14 -16
View File
@@ -1,28 +1,26 @@
@page "/Error" @page "/Error"
@using System.Diagnostics @using System.Diagnostics
<PageTitle>Ошибка</PageTitle> <PageTitle>Ошибка — GM-Relay</PageTitle>
<h1 class="text-danger">Ошибка.</h1> <div class="page-container">
<h2 class="text-danger">Произошла ошибка при обработке вашего запроса.</h2> <div class="error-page">
<div class="error-page-icon">⚠️</div>
<h1 class="error-page-title">Произошла ошибка</h1>
<p class="error-page-text">При обработке вашего запроса что-то пошло не так. Пожалуйста, попробуйте снова.</p>
@if (ShowRequestId) @if (ShowRequestId)
{ {
<p> <p style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
<strong>ID запроса:</strong> <code>@RequestId</code> ID запроса: @RequestId
</p> </p>
} }
<h3>Режим разработки</h3> <a href="/" class="btn-gm btn-gm-primary" style="margin-top: 0.5rem;">
<p> ← На главную
Переключение на среду <strong>Development</strong> отобразит более подробную информацию о произошедшей ошибке. </a>
</p> </div>
<p> </div>
<strong>Среда Development не должна быть включена для развернутых приложений.</strong>
Это может привести к отображению конфиденциальной информации из исключений конечным пользователям.
Для локальной отладки включите среду <strong>Development</strong>, установив переменную среды <strong>ASPNETCORE_ENVIRONMENT</strong> в значение <strong>Development</strong>
и перезапустите приложение.
</p>
@code{ @code{
[CascadingParameter] [CascadingParameter]
@@ -2,35 +2,49 @@
@using GmRelay.Web.Services @using GmRelay.Web.Services
@using GmRelay.Shared.Domain @using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize] @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"> <div class="page-container">
<nav aria-label="breadcrumb"> <ul class="gm-breadcrumb animate-fade-in">
<ol class="breadcrumb"> <li><a href="/">Главная</a></li>
<li class="breadcrumb-item"><a href="/">Главная</a></li> <li class="active">Сессии группы</li>
<li class="breadcrumb-item active">Сессии группы</li> </ul>
</ol>
</nav>
<h2>Предстоящие игры</h2> <div class="page-header animate-fade-in">
<h2>📅 Предстоящие игры</h2>
</div>
<div class="mt-4">
@if (sessions == null) @if (sessions == null)
{ {
<p>Загрузка сессий...</p> <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) else if (sessions.Count == 0)
{ {
<div class="alert alert-info">Для этой группы не найдено предстоящих сессий.</div> <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 else
{ {
<div class="table-responsive"> @* Desktop table *@
<table class="table table-hover align-middle"> <div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
<thead class="table-light"> <table class="gm-table">
<thead>
<tr> <tr>
<th>Название</th> <th>Название</th>
<th>Время (МСК)</th> <th>Время (МСК)</th>
@@ -43,22 +57,56 @@
@foreach (var session in sessions) @foreach (var session in sessions)
{ {
<tr> <tr>
<td>@session.Title</td> <td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
<td>@session.ScheduledAt.FormatMoscow()</td> <td>@session.ScheduledAt.FormatMoscow()</td>
<td> <td>
<span class="badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span> <span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</td> </td>
<td><a href="@session.JoinLink" target="_blank" class="text-truncate d-inline-block" style="max-width: 150px;">Ссылка</a></td>
<td> <td>
<a href="/session/edit/@session.Id" class="btn btn-sm btn-outline-secondary">Изменить</a> <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>
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;">
✏️ Изменить
</a>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </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>
<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>
</div>
</div>
} }
</div> </div>
}
</div> </div>
@code { @code {
@@ -67,23 +115,33 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
sessions = await SessionService.GetUpcomingSessionsAsync(GroupId); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
if (sessions is null)
{
Navigation.NavigateTo("/access-denied");
}
} }
private string GetStatusClass(string status) => status switch private string GetStatusClass(string status) => status switch
{ {
SessionStatus.Confirmed => "bg-success", SessionStatus.Confirmed => "status-success",
SessionStatus.Cancelled => "bg-danger", SessionStatus.Cancelled => "status-danger",
SessionStatus.ConfirmationSent => "bg-warning text-dark", SessionStatus.ConfirmationSent => "status-warning",
_ => "bg-secondary" SessionStatus.Planned => "status-info",
_ => "status-neutral"
}; };
private string TranslateStatus(string status) => status switch private string TranslateStatus(string status) => status switch
{ {
"Recruiting" => "Набор",
"RecruitmentClosed" => "Набор закрыт",
SessionStatus.Planned => "Запланировано", SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения", SessionStatus.ConfirmationSent => "Ждём подтверждения",
SessionStatus.Confirmed => "Подтверждено", SessionStatus.Confirmed => "Подтверждено",
SessionStatus.Cancelled => "Отменено", SessionStatus.Cancelled => "Отменено",
_ => status _ => status
+60 -24
View File
@@ -3,47 +3,81 @@
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using GmRelay.Web.Services @using GmRelay.Web.Services
@attribute [Authorize] @attribute [Authorize]
@inject SessionService SessionService @inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Панель управления - GM-Relay</PageTitle> <PageTitle>Панель управления GM-Relay</PageTitle>
<div class="container mt-4"> <div class="page-container">
<h2>Добро пожаловать, @userName!</h2> <div class="page-header animate-fade-in">
<p class="text-muted">Выберите группу для управления играми.</p> <h2>Добро пожаловать, @userName! 👋</h2>
<p>Выберите группу для управления игровыми сессиями.</p>
</div>
<div class="row mt-4">
@if (groups == null) @if (groups == null)
{ {
<p>Загрузка групп...</p> <div class="card-grid">
@for (int i = 0; i < 3; i++)
{
<div class="skeleton skeleton-card"></div>
}
</div>
} }
else if (groups.Count == 0) else if (groups.Count == 0)
{ {
<div class="col-12"> <div class="glass-card">
<div class="card bg-light"> <div class="empty-state">
<div class="card-body text-center"> <div class="empty-state-icon">🤖</div>
<p class="mb-0">У вас еще нет зарегистрированных групп. Сначала добавьте бота в группу Telegram!</p> <div class="empty-state-title">Нет зарегистрированных групп</div>
</div> <p class="empty-state-text">Добавьте бота GM-Relay в свою группу Telegram, чтобы начать управлять игровыми сессиями.</p>
</div> </div>
</div> </div>
} }
else else
{ {
<div class="card-grid stagger-children">
@foreach (var group in groups) @foreach (var group in groups)
{ {
<div class="col-md-4 mb-3"> <div class="glass-card group-card">
<div class="card h-100 shadow-sm"> <div class="group-card-icon">🎮</div>
<div class="card-body"> <h3 class="group-card-title">@group.Name</h3>
<h5 class="card-title">@group.Name</h5> <p class="group-card-id">ID: @group.TelegramChatId</p>
<p class="card-text text-muted">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 href="/group/@group.Id" class="btn btn-primary">Посмотреть игры</a> Посмотреть игры
</div> </a>
</div>
</div> </div>
} }
</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 { @code {
private List<WebGameGroup>? groups; private List<WebGameGroup>? groups;
@@ -55,10 +89,12 @@
var user = authState.User; var user = authState.User;
userName = user.Identity?.Name ?? "Мастер Игры"; userName = user.Identity?.Name ?? "Мастер Игры";
var telegramIdClaim = user.FindFirst("TelegramId")?.Value; if (!user.TryGetTelegramId(out var telegramId))
if (long.TryParse(telegramIdClaim, out var telegramId))
{ {
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId); groups = await SessionService.GetGroupsForGmAsync(telegramId);
} }
} }
}
+19 -18
View File
@@ -2,31 +2,24 @@
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IConfiguration Configuration @inject IConfiguration Configuration
@inject IJSRuntime JS
<PageTitle>Вход - GM-Relay</PageTitle> <PageTitle>Вход GM-Relay</PageTitle>
<div class="container"> <div class="login-page">
<div class="row justify-content-center mt-5"> <div class="login-card">
<div class="col-md-6 text-center"> <div class="login-logo">🎲</div>
<h3>Панель управления GM-Relay</h3> <h1 class="login-title">GM-Relay</h1>
<p class="text-muted">Пожалуйста, войдите как Мастер Игры для управления сессиями.</p> <p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
<div class="mt-4">
@if (Navigation.Uri.Contains("error=auth_failed")) @if (Navigation.Uri.Contains("error=auth_failed"))
{ {
<div class="alert alert-danger">Ошибка аутентификации. Пожалуйста, попробуйте снова.</div> <div class="gm-alert gm-alert-danger" style="margin-bottom: 1.5rem; justify-content: center;">
⚠️ Ошибка аутентификации. Пожалуйста, попробуйте снова.
</div>
} }
@* Telegram Login Widget *@ <div id="telegram-login-container"></div>
<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>
</div>
</div>
</div> </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 @layout MainLayout
<h3>Не найдено</h3> <PageTitle>404 — GM-Relay</PageTitle>
<p>Извините, страница, которую вы ищете, не существует.</p>
<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 // Add Services
builder.Services.AddSingleton<TelegramAuthService>(); builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.AddSingleton<SessionService>(); builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
// Add Bot Client // Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp => builder.Services.AddSingleton<ITelegramBotClient>(sp =>
@@ -32,12 +33,17 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
return new TelegramBotClient(token); return new TelegramBotClient(token);
}); });
// Add Authentication // Add Authentication with hardened cookie settings
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options => .AddCookie(options =>
{ {
options.LoginPath = "/login"; options.LoginPath = "/login";
options.AccessDeniedPath = "/access-denied"; 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(); builder.Services.AddAuthorization();
@@ -58,6 +64,16 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection(); 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.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseAntiforgery(); app.UseAntiforgery();
@@ -0,0 +1,45 @@
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)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
}
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink);
}
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);
}
+10
View File
@@ -0,0 +1,10 @@
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);
}
@@ -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}'.");
+36 -17
View File
@@ -11,7 +11,8 @@ public sealed record WebSession(Guid Id, Guid GroupId, string Title, DateTime Sc
public sealed class SessionService( public sealed class SessionService(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot) ITelegramBotClient bot,
ILogger<SessionService> logger) : ISessionStore
{ {
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
{ {
@@ -21,6 +22,14 @@ public sealed class SessionService(
new { GmId = gmId })).ToList(); 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) public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
@@ -48,29 +57,41 @@ public sealed class SessionService(
new { SessionId = sessionId }); new { SessionId = sessionId });
} }
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)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync(); await using var transaction = await conn.BeginTransactionAsync();
// 1. Fetch current session with all required columns for WebSession record var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
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, @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id", WHERE s.id = @Id AND s.group_id = @GroupId",
new { Id = sessionId }, transaction); new { Id = sessionId, GroupId = groupId },
// 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 },
transaction); 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,
updated_at = now()
WHERE id = @Id AND group_id = @GroupId",
new { Id = sessionId, GroupId = groupId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink },
transaction);
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(sessionId, 0);
}
await conn.ExecuteAsync( await conn.ExecuteAsync(
"UPDATE sessions SET title = @Title WHERE batch_id = @BatchId", "UPDATE sessions SET title = @Title WHERE batch_id = @BatchId",
new { Title = title, BatchId = oldSession.BatchId }, new { Title = title, BatchId = oldSession.BatchId },
@@ -78,7 +99,6 @@ public sealed class SessionService(
await transaction.CommitAsync(); await transaction.CommitAsync();
// 4. Send Telegram Notification
var timeChanged = oldSession.ScheduledAt != scheduledAt; var timeChanged = oldSession.ScheduledAt != scheduledAt;
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" + var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
$"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" + $"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" +
@@ -86,7 +106,6 @@ public sealed class SessionService(
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
// 5. Update Original Batch Message
if (oldSession.BatchMessageId.HasValue) if (oldSession.BatchMessageId.HasValue)
{ {
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title); await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
@@ -121,9 +140,9 @@ public sealed class SessionService(
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup); 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); var dataCheckString = string.Join("\n", dataCheckList);
// 2. Compute Secret Key // 2. Compute Secret Key (static method — no IDisposable needed)
using var sha256 = SHA256.Create(); var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(token));
var secretKey = sha256.ComputeHash(Encoding.UTF8.GetBytes(token));
// 3. Compute Hash // 3. Compute Hash (static method — no IDisposable needed)
using var hmac = new HMACSHA256(secretKey); var computedHashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
var computedHashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataCheckString));
var computedHash = Convert.ToHexString(computedHashBytes).ToLower();
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; 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)) if (query.TryGetValue("auth_date", out var authDateStr) && long.TryParse(authDateStr, out var authDate))
{ {
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+794 -30
View File
@@ -1,60 +1,824 @@
/* ============================================
GM-Relay Design System v1.1.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 { 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 { /* === Typography === */
color: #006bb7; 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 { h1 { font-size: 1.875rem; }
color: #fff; h2 { font-size: 1.5rem; }
background-color: #1b6ec2; h3 { font-size: 1.25rem; }
border-color: #1861ac;
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 { a:hover {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; color: #22d3ee;
} }
.content { /* === Scrollbar === */
padding-top: 1.1rem; ::-webkit-scrollbar {
width: 6px;
height: 6px;
} }
h1:focus { ::-webkit-scrollbar-track {
outline: none; background: transparent;
} }
.valid.modified:not([type=checkbox]) { ::-webkit-scrollbar-thumb {
outline: 1px solid #26b050; background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
} }
.invalid { ::-webkit-scrollbar-thumb:hover {
outline: 1px solid #e50000; background: rgba(255, 255, 255, 0.25);
} }
.validation-message { /* === Glass Card === */
color: #e50000; .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 { .glass-card:hover {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; background: var(--bg-card-hover);
padding: 1rem 1rem 1rem 3.7rem; 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; color: white;
} }
.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 { .blazor-error-boundary::after {
content: "An error has occurred." content: "Произошла ошибка при отображении этого компонента."
} }
.darker-border-checkbox.form-check-input { /* Bootstrap overrides for dark theme */
border-color: #929292; .form-label {
color: var(--text-secondary) !important;
font-size: 0.8125rem !important;
font-weight: 500 !important;
} }
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { .form-text {
color: var(--bs-secondary-color); color: var(--text-muted) !important;
text-align: end; font-size: 0.75rem !important;
} }
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { /* === Login page background === */
text-align: start; .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;
}
.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;
}
}
/* === 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;
}
.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,68 @@
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
Ссылка: 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(
[
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);
}
}
@@ -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> <ItemGroup>
<ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" /> <ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" />
<ProjectReference Include="..\..\src\GmRelay.Web\GmRelay.Web.csproj" />
</ItemGroup> </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,50 @@
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),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice"),
new ParticipantBatchDto(cancelledSessionId, "Bob", null)
};
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("@alice", 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));
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace GmRelay.Bot.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
@@ -0,0 +1,183 @@
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)
]);
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)
]);
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)
]);
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)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b");
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)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b");
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);
}
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 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 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)
{
UpdateCalled = true;
LastUpdatedSessionId = sessionId;
LastUpdatedGroupId = groupId;
LastUpdatedTitle = title;
LastUpdatedScheduledAt = scheduledAt;
LastUpdatedJoinLink = joinLink;
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();
}
}