Compare commits

..

8 Commits

Author SHA1 Message Date
Toutsu e0602052ea review fixes: complete success path, pool capacity navigation, time parser tests
PR Checks / test-and-build (pull_request) Successful in 24m27s
- DiscordWizardSubmitter.SubmitAsync: confirm success, delete draft, clear context.
- GameCreationWizard: pool free-text duration now advances to Capacity.
- PreviousStep(Capacity) returns PoolSystemDuration for pools.
- Remove unused optional IPlatformMessenger/NpgsqlDataSource from submitter.
- Add DiscordTimeParserTests preserving ParseTimeInput coverage.
2026-06-15 18:37:23 +03:00
Toutsu 9709d09b15 feat(discord): make /newsession identical to Telegram wizard
PR Checks / test-and-build (pull_request) Successful in 30m45s
- Remove legacy DiscordNewSessionCommand/Handler and their tests.
- Rename /newsession-wizard to /newsession.
- Add shared pool capacity step before Format/Location.
- Render Format and Location in Discord wizard; Location uses a modal.
- Propagate Format, JoinLink and LocationAddress in BuildCommand.
- Publish created sessions through existing IPlatformMessenger pipeline.
- Update README, version bump to 3.11.1, sync compose/deploy/NavMenu.
2026-06-15 17:49:53 +03:00
Toutsu a391c51761 feat(listsessions): add join/leave buttons for players
Deploy Telegram Bot / build-and-push (push) Successful in 21m33s
Deploy Telegram Bot / scan-images (push) Successful in 8m55s
Deploy Telegram Bot / deploy (push) Successful in 2m3s
For non-managers /listsessions now shows player-friendly actions:
-  Записаться <date> when not registered
- ✖️ Выйти <date> when already active
- ✖️ Выйти из ожидания <date> when waitlisted

Extend SessionListItemDto and the shared SQL query with IsUserActive
and IsUserWaitlisted flags so the renderer can choose the right button.
Update tests to cover all three player states.
2026-06-15 15:05:12 +03:00
Toutsu e15652399b feat(bot): register Telegram command menu on startup
Deploy Telegram Bot / build-and-push (push) Successful in 23m54s
Deploy Telegram Bot / scan-images (push) Successful in 9m14s
Deploy Telegram Bot / deploy (push) Successful in 1m41s
Set /start, /newsession, /listsessions, /exportcalendar and /help
via setMyCommands for both private chats and group chats so users
see the command list when typing '/'.

Also update /help text to list all commands first and then show the
example.
2026-06-15 11:41:33 +03:00
Toutsu 40b13db320 fix(list-sessions): clarify manager action button labels
Deploy Telegram Bot / build-and-push (push) Successful in 3m6s
Deploy Telegram Bot / scan-images (push) Successful in 8m43s
Deploy Telegram Bot / deploy (push) Successful in 2m5s
The /listsessions buttons for owners/co-GMs only showed emoji + date,
so it was unclear what each button did. Add explicit verb labels:
-  Отменить <date>
-  Перенести <date>
- ⬆️ С ожидания <date>
- 🗑 Удалить <date>

Update the renderer test to assert the new labels.
2026-06-15 10:57:15 +03:00
Toutsu e0ee8fc962 fix(list-sessions): clarify manager action button labels
The /listsessions buttons for owners/co-GMs only showed emoji + date,
so it was unclear what each button did. Add explicit verb labels:
-  Отменить <date>
-  Перенести <date>
- ⬆️ С ожидания <date>
- 🗑 Удалить <date>

Update the renderer test to assert the new labels.
2026-06-15 10:56:42 +03:00
Toutsu 6707a2850c fix(list-sessions): clarify manager action button labels
Deploy Telegram Bot / build-and-push (push) Successful in 23m5s
Deploy Telegram Bot / scan-images (push) Failing after 13m20s
Deploy Telegram Bot / deploy (push) Has been skipped
The /listsessions buttons for owners/co-GMs only showed emoji + date,
so it was unclear what each button did. Add explicit verb labels:
-  Отменить <date>
-  Перенести <date>
- ⬆️ С ожидания <date>
- 🗑 Удалить <date>

Update the renderer test to assert the new labels.
2026-06-15 10:55:59 +03:00
Toutsu d137c334d6 Merge pull request 'ci(deploy): increase trivy image scan timeout to 30m' (#141) from fix/deploy-trivy-image-timeout into main
Deploy Telegram Bot / build-and-push (push) Successful in 6m5s
Deploy Telegram Bot / scan-images (push) Successful in 9m39s
Deploy Telegram Bot / deploy (push) Successful in 2m9s
Merge pull request #141: ci(deploy): increase trivy image scan timeout to 30m
2026-06-13 20:28:21 +03:00
30 changed files with 680 additions and 572 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.11.0
VERSION: 3.11.1
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.11.0</Version>
<Version>3.11.1</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+2 -2
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v3.10.0`.
**Текущая версия:** `v3.11.1`.
---
@@ -25,7 +25,7 @@
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах.
### Discord Bot
- **Slash-команды `/newsession` и `/listsessions`**: GM создаёт сессии и публикует актуальное расписание прямо в Discord-канале.
- **Slash-команды `/newsession` и `/listsessions`**: `/newsession` ведёт ГМа по тому же wizard, что и в Telegram: тип, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка или адрес, видимость и публикация. `/listsessions` показывает расписание и управление сессиями.
- **Кнопки Join/Leave с ephemeral-ответами**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
- **RSVP (подтверждения) за 24ч до сессии**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает итоги RSVP.
- **DM-напоминания за 1ч и ссылки перед игрой**: one-hour reminders и join-link notifications отправляются в Discord DM при включённых личных уведомлениях; сбои DM логируются без публичного fallback.
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.1
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.0
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.1
restart: always
depends_on:
db:
@@ -86,7 +86,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.0
image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.1
restart: always
depends_on:
db:
@@ -23,11 +23,18 @@ internal static class SessionListMessageRenderer
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
{
if (sessions.Count == 0 || !sessions.First().CanManage)
if (sessions.Count == 0)
{
return [];
}
return sessions.First().CanManage
? RenderManagerActions(sessions)
: RenderPlayerActions(sessions);
}
private static IReadOnlyList<PlatformMessageAction> RenderManagerActions(IReadOnlyList<SessionListItemDto> sessions)
{
var actions = new List<PlatformMessageAction>();
foreach (var session in sessions)
@@ -36,19 +43,19 @@ internal static class SessionListMessageRenderer
actions.Add(new PlatformMessageAction(
$"cancel_session:{session.Id}",
$"❌ {dateTitle}",
$"❌ Отменить {dateTitle}",
$"cancel_session:{session.Id}"));
actions.Add(new PlatformMessageAction(
$"reschedule_session:{session.Id}",
$"⏰ {dateTitle}",
$"⏰ Перенести {dateTitle}",
$"reschedule_session:{session.Id}"));
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
actions.Add(new PlatformMessageAction(
$"promote_waitlist:{session.Id}",
$"⬆️ Из ожидания {dateTitle}",
$"⬆️ С ожидания {dateTitle}",
$"promote_waitlist:{session.Id}"));
}
@@ -60,4 +67,31 @@ internal static class SessionListMessageRenderer
return actions;
}
private static IReadOnlyList<PlatformMessageAction> RenderPlayerActions(IReadOnlyList<SessionListItemDto> sessions)
{
var actions = new List<PlatformMessageAction>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
if (session.IsUserActive || session.IsUserWaitlisted)
{
actions.Add(new PlatformMessageAction(
$"leave_session:{session.Id}",
session.IsUserWaitlisted ? $"✖️ Выйти из ожидания {dateTitle}" : $"✖️ Выйти {dateTitle}",
$"leave_session:{session.Id}"));
}
else
{
actions.Add(new PlatformMessageAction(
$"join_session:{session.Id}",
$"✅ Записаться {dateTitle}",
$"join_session:{session.Id}"));
}
}
return actions;
}
}
@@ -0,0 +1,46 @@
using Telegram.Bot;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Infrastructure.Telegram;
/// <summary>
/// Registers the bot's command list with Telegram so users see the
/// command menu when they type "/" in a chat.
/// </summary>
public sealed class TelegramCommandsSetupService(
ITelegramBotClient bot,
ILogger<TelegramCommandsSetupService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var commands = new[]
{
new BotCommand { Command = "start", Description = "Начать работу с ботом" },
new BotCommand { Command = "newsession", Description = "Создать новую игровую сессию" },
new BotCommand { Command = "listsessions", Description = "Список предстоящих сессий" },
new BotCommand { Command = "exportcalendar", Description = "Экспортировать расписание в ICS" },
new BotCommand { Command = "help", Description = "Справка по командам" }
};
try
{
await bot.SetMyCommands(
commands,
scope: new BotCommandScopeAllPrivateChats(),
cancellationToken: cancellationToken);
await bot.SetMyCommands(
commands,
scope: new BotCommandScopeAllGroupChats(),
cancellationToken: cancellationToken);
logger.LogInformation("Telegram command menu registered for private chats and groups.");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to register Telegram command menu.");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -366,6 +366,13 @@ public sealed class UpdateRouter(
text: """
GM-Relay бот для управления игровыми сессиями.
/start начать работу с ботом
/newsession создать новую игровую сессию
/listsessions список предстоящих сессий
/exportcalendar экспортировать расписание в ICS
/help эта справка
Пример создания сессии:
/newsession
Название: My Game
Время: 15.05.2026 19:30
@@ -377,10 +384,8 @@ public sealed class UpdateRouter(
Игр: 4
Интервал: 7
/listsessions список предстоящих сессий
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
/help эта справка
""",
cancellationToken: ct);
break;
+1
View File
@@ -98,6 +98,7 @@ builder.Services.AddSingleton<DirectSessionNotificationSender>();
// ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>();
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
builder.Services.AddHostedService<TelegramCommandsSetupService>();
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
builder.Services.AddHostedService<TelegramBotService>();
@@ -1,128 +0,0 @@
using GmRelay.DiscordBot.Rendering;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordNewSessionHandler _handler;
private readonly ILogger<DiscordNewSessionCommand> _logger;
public DiscordNewSessionCommand(DiscordNewSessionHandler handler, ILogger<DiscordNewSessionCommand> logger)
{
_handler = handler;
_logger = logger;
}
[SlashCommand("newsession", "Create a new game session")]
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "title", Description = "Game title")] string title,
[SlashCommandParameter(Name = "time", Description = "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)")] string time,
[SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null,
[SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null)
{
_logger.LogInformation(
"newsession called by user {UserId} ({UserType}) in guild {GuildId}, channel {ChannelId}",
Context.User.Id,
Context.User.GetType().Name,
Context.Interaction.GuildId,
Context.Channel?.Id);
var guildId = Context.Interaction.GuildId
?? throw new InvalidOperationException("This command can only be used in a guild.");
var member = Context.User as GuildInteractionUser;
if (member is null)
{
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
throw new InvalidOperationException("Guild member data not available in interaction.");
}
var resolvedPermissions = (ulong)member.Permissions;
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
ulong guildOwnerId = 0;
var guildName = guildId.ToString();
try
{
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
guildOwnerId = guild.OwnerId;
guildName = guild.Name;
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
}
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogWarning(
ex,
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
guildId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
}
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
if (!timeResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"X {timeResult.Error}"));
return;
}
// Defer the response to avoid Discord 3-second interaction timeout
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
try
{
_logger.LogInformation("Creating session for guild {GuildId}, user {UserId}", guildId, Context.User.Id);
var view = await _handler.HandleAsync(
guildId: guildId.ToString(),
channelId: Context.Channel!.Id.ToString(),
groupName: guildName,
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guildOwnerId,
title: title,
scheduledAt: timeResult.Value,
maxPlayers: seats is null ? null : (int)seats.Value,
joinLink: link,
CancellationToken.None);
_logger.LogInformation("Session created successfully. Building render.");
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
_logger.LogInformation("Sending success response.");
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = ":white_check_mark: **Session created successfully!**";
message.Embeds = embeds;
message.Components = actionRows;
});
_logger.LogInformation("Success response sent.");
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Unauthorized session creation attempt by user {UserId}", Context.User.Id);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $":no_entry: {ex.Message}";
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guildId);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = ":boom: An error occurred while creating the session.";
});
}
}
}
@@ -1,156 +0,0 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using System.Globalization;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
ILogger<DiscordNewSessionHandler> logger)
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
public static TimeParseResult ParseTimeInput(string input)
{
var trimmed = input.Trim();
if (DateTime.TryParseExact(
trimmed,
"yyyy-MM-dd HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt1))
{
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, offset, null);
}
if (DateTime.TryParseExact(
trimmed,
"dd.MM.yyyy HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt2))
{
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, offset, null);
}
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
}
public async Task<SessionBatchViewModel> HandleAsync(
string guildId,
string channelId,
string groupName,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
ulong guildOwnerId,
string title,
DateTimeOffset scheduledAt,
int? maxPlayers,
string? joinLink,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
? title
: groupName.Trim();
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord'
AND p.platform = 'Discord'
AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var transactionCommitted = false;
try
{
await connection.ExecuteAsync(
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Discord', @UserId, @Name)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username",
new { Name = userDisplayName, UserId = userId.ToString() },
transaction);
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GroupName, 'Discord', @GuildId, @ChannelId)
ON CONFLICT (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
DO UPDATE SET name = EXCLUDED.name,
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
RETURNING id",
new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
transaction);
await connection.ExecuteAsync(
@"INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = 'Discord' AND p.external_user_id = @UserId
ON CONFLICT (group_id, player_id) DO NOTHING",
new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
var batchId = Guid.NewGuid();
var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
RETURNING id",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = joinLink ?? string.Empty,
ScheduledAt = scheduledAt.UtcDateTime,
Status = SessionStatus.Planned,
MaxPlayers = maxPlayers
},
transaction);
await transaction.CommitAsync(cancellationToken);
transactionCommitted = true;
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
}
catch
{
if (!transactionCommitted)
{
await transaction.RollbackAsync(cancellationToken);
}
throw;
}
}
}
@@ -75,7 +75,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
var parsedOptions = new List<DateTimeOffset>();
foreach (var opt in options)
{
var result = DiscordNewSessionHandler.ParseTimeInput(opt);
var result = DiscordTimeParser.ParseTimeInput(opt);
if (!result.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
@@ -85,7 +85,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
parsedOptions.Add(result.Value);
}
var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline);
var deadlineResult = DiscordTimeParser.ParseTimeInput(deadline);
if (!deadlineResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
@@ -0,0 +1,45 @@
using System.Globalization;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public static class DiscordTimeParser
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
public static TimeParseResult ParseTimeInput(string input)
{
var trimmed = input.Trim();
if (DateTime.TryParseExact(
trimmed,
"yyyy-MM-dd HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt1))
{
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, offset, null);
}
if (DateTime.TryParseExact(
trimmed,
"dd.MM.yyyy HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt2))
{
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, offset, null);
}
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
}
}
@@ -8,10 +8,9 @@ using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// Small lookup helper for Discord permission checks. The
/// <see cref="DiscordNewSessionHandler"/> already runs the same SQL
/// inline; this class is here so the wizard slash command can do the
/// same check without duplicating the query string.
/// Small lookup helper for Discord permission checks. The slash command
/// and reschedule command both need to enumerate DB managers for a guild;
/// this class centralises the query so it isn't duplicated.
/// </summary>
internal static class DiscordPermissionLookup
{
@@ -13,7 +13,7 @@ namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
/// <summary>
/// Slash entry point for the Discord wizard. Mirrors the Telegram
/// <c>/newsession-wizard</c> command: a fresh draft is created on
/// <c>/newsession</c> command: a fresh draft is created on
/// first invocation, the persisted first-step message is re-shown
/// when the user already has an active draft, and the owner/co-GM
/// permission check from <see cref="DiscordPermissionChecker"/> is
@@ -44,7 +44,7 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
_log = log;
}
[SlashCommand("newsession-wizard", "Пошаговое создание игры или пула")]
[SlashCommand("newsession", "Пошаговое создание игры или пула")]
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "mode", Description = "Пропустить выбор типа (single/pool)")] string? mode = null)
{
@@ -132,6 +132,7 @@ public sealed class WizardInteractionDispatcher
WizardStepNames.Cover,
WizardStepNames.DateTime,
WizardStepNames.Capacity,
WizardStepNames.Location,
WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolSlotCapacity,
"SystemFreeText",
@@ -166,7 +167,7 @@ public sealed class WizardInteractionDispatcher
{
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
@@ -276,14 +277,14 @@ public sealed class WizardInteractionDispatcher
// itself doesn't know about resume, so we just edit the
// draft message via the messenger).
// resume:restart → delete the draft and prompt the user to
// re-run /newsession-wizard.
// re-run /newsession.
if (parts.Length >= 3 && parts[2] == "restart")
{
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.")
.WithContent("♻️ Мастер сброшен. Запустите /newsession заново.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
@@ -314,7 +315,7 @@ public sealed class WizardInteractionDispatcher
{
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
@@ -365,7 +366,7 @@ public sealed class WizardInteractionDispatcher
{
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
.WithFlags(MessageFlags.Ephemeral)));
return;
}
@@ -60,6 +60,8 @@ public static class DiscordWizardStep
WizardStepNames.Duration => RenderDuration(),
WizardStepNames.DateTime => RenderDateTime(),
WizardStepNames.Capacity => RenderCapacity(),
WizardStepNames.Format => RenderFormat(),
WizardStepNames.Location => RenderLocation(payload),
WizardStepNames.Visibility => RenderVisibility(),
WizardStepNames.PickClub => RenderPickClub(clubs ?? System.Array.Empty<WizardClubOption>()),
WizardStepNames.Publish => RenderPublish(),
@@ -263,6 +265,29 @@ public static class DiscordWizardStep
},
OpenModalStep: WizardStepNames.Capacity);
private static DiscordWizardRender RenderFormat() => new(
"🧭 Формат игры",
"Выберите формат.",
new[]
{
Row(ChoiceBtn("🌐 Online", WizardStepNames.Format, "online", ButtonStyle.Primary),
ChoiceBtn("📍 Offline", WizardStepNames.Format, "offline", ButtonStyle.Primary)),
Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
OpenModalStep: null);
private static DiscordWizardRender RenderLocation(WizardPayload payload)
{
var isOnline = payload.Format == WizardSessionFormat.Online;
return new DiscordWizardRender(
isOnline ? "🔗 Ссылка" : "📍 Адрес",
isOnline ? "Введите ссылку для подключения." : "Введите адрес места проведения.",
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
OpenModalStep: WizardStepNames.Location);
}
private static DiscordWizardRender RenderVisibility() => new(
"🔒 Видимость",
"Выберите, кто увидит сессию.",
@@ -566,6 +591,20 @@ public static class DiscordWizardStep
Required = true,
}),
}),
WizardStepNames.Location => new ModalProperties(
ModalCustomId(WizardStepNames.Location),
"🔗 Ссылка / 📍 Адрес",
new IModalComponentProperties[]
{
new LabelProperties(
"Ссылка или адрес",
new TextInputProperties(ModalCustomId(WizardStepNames.Location), TextInputStyle.Short)
{
Placeholder = "https://… или адрес",
MaxLength = WizardStepLimits.MaxLocationLength,
Required = true,
}),
}),
WizardStepNames.PoolSlotDateTime => new ModalProperties(
ModalCustomId(WizardStepNames.PoolSlotDateTime),
"📅 Дата/время слота",
@@ -65,19 +65,32 @@ public sealed class DiscordWizardSubmitter
return;
}
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
try
{
var commands = BuildCommands(draft, payload);
foreach (var cmd in commands)
{
await _shared.HandleAsync(cmd, ct);
var result = await _shared.HandleAsync(cmd, ct);
if (!result.Success)
{
await EditDraftMessageAsync(
draft,
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
Array.Empty<WizardAction>(),
ct);
return;
}
created.Add((cmd, result));
}
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
await EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
Array.Empty<WizardAction>(),
ct);
// Success: replace the wizard message with a confirmation and
// clean up the draft so the user can start a new one later.
var confirmation = created.Count == 1
? $"✅ Создано: {created[0].Command.Title}"
: $"✅ Создано: {created[0].Command.Title} и ещё {created.Count - 1} сессия/сессии";
await EditDraftMessageAsync(draft, confirmation, Array.Empty<WizardAction>(), ct);
await _drafts.DeleteAsync(draft.Id, ct);
_contextStore.Remove(draft.Id);
}
@@ -90,7 +103,7 @@ public sealed class DiscordWizardSubmitter
{
await EditDraftMessageAsync(
draft,
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession-wizard, чтобы начать заново.",
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
@@ -140,7 +153,7 @@ public sealed class DiscordWizardSubmitter
}
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
pool.MaxPlayers ?? (pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers));
internal static CreateSessionCommand BuildCommand(
WizardDraft draft,
@@ -164,15 +177,16 @@ public sealed class DiscordWizardSubmitter
User: user,
Group: group,
Title: p.Title ?? string.Empty,
Link: string.Empty,
Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty,
ScheduledTimes: scheduledTimes,
MaxPlayers: maxPlayers,
ImageReference: p.ImageFileId ?? p.ImageUrl,
System: ParseSystem(p.System),
Description: p.Description,
Format: null,
Format: p.Format?.ToString(),
DurationMinutes: p.DurationMinutes,
IsOneShot: isOneShot);
IsOneShot: isOneShot,
LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null);
}
private static GameSystem? ParseSystem(string? code)
@@ -188,12 +202,15 @@ public sealed class DiscordWizardSubmitter
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
if (p.Format is null) missingFields.Add("формат");
if (p.Format == WizardSessionFormat.Online && string.IsNullOrWhiteSpace(p.JoinLink)) missingFields.Add("ссылка");
if (p.Format == WizardSessionFormat.Offline && string.IsNullOrWhiteSpace(p.LocationAddress)) missingFields.Add("адрес");
if (p.Visibility is null) missingFields.Add("видимость");
if (p.Type == WizardCreationType.Single)
{
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
// MaxPlayers = null is a valid "♾ Без лимита" choice.
}
else
{
-1
View File
@@ -61,7 +61,6 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
@@ -224,7 +224,16 @@ public sealed class GameCreationWizard
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
case WizardStepNames.Capacity:
if (payload.Type == WizardCreationType.Pool)
{
if (payload.Pool?.MaxPlayers is not null) return (null, "Лимит уже задан", payload);
return int.TryParse(input, out var poolCap) && poolCap >= WizardStepLimits.MinCapacity && poolCap <= WizardStepLimits.MaxCapacity
? (WizardStepNames.Format, SetPoolMaxPlayers(payload, poolCap), payload)
: (null, "Лимит должен быть 1..50", payload);
}
if (payload.Single?.MaxPlayers is not null) return (null, "Лимит уже задан", payload);
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
? (WizardStepNames.Format, SetMaxPlayers(payload, cap), payload)
: (null, "Лимит должен быть 1..50", payload);
@@ -247,7 +256,7 @@ public sealed class GameCreationWizard
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
return TryParseHours(input, out var pdur)
? (WizardStepNames.Format, SetDurationMinutes(payload, pdur), payload)
? (WizardStepNames.Capacity, SetDurationMinutes(payload, pdur), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.PoolSlotDateTime:
@@ -314,10 +323,12 @@ public sealed class GameCreationWizard
{
if (choice is "no_limit")
{
return (WizardStepNames.Format, SetMaxPlayers(p, null));
return p.Type == WizardCreationType.Pool
? (WizardStepNames.Format, SetPoolMaxPlayers(p, null))
: (WizardStepNames.Format, SetMaxPlayers(p, null));
}
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
if (choice is "waitlist:on" or "waitlist:off" && p.Type != WizardCreationType.Pool && p.Single?.MaxPlayers is null)
{
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
}
@@ -368,11 +379,12 @@ public sealed class GameCreationWizard
{
"_custom" => (WizardStepNames.PoolSystemDuration, null),
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
? (WizardStepNames.Format, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
? (WizardStepNames.Capacity, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
: (null, "Неверный выбор"),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch
{
"add" => BeginNewPoolSlot(p),
@@ -409,8 +421,8 @@ public sealed class GameCreationWizard
WizardStepNames.System => WizardStepNames.Cover,
WizardStepNames.Duration => WizardStepNames.System,
WizardStepNames.DateTime => WizardStepNames.Duration,
WizardStepNames.Capacity => WizardStepNames.DateTime,
WizardStepNames.Format => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.Capacity,
WizardStepNames.Capacity => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.DateTime,
WizardStepNames.Format => WizardStepNames.Capacity,
WizardStepNames.Location => WizardStepNames.Format,
WizardStepNames.Visibility => WizardStepNames.Location,
WizardStepNames.PickClub => WizardStepNames.Visibility,
@@ -458,6 +470,8 @@ public sealed class GameCreationWizard
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
private static string? SetMaxPlayers(WizardPayload p, int? v)
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
private static string? SetPoolMaxPlayers(WizardPayload p, int? v)
{ p.Pool ??= new WizardPoolInput(); p.Pool.MaxPlayers = v; return null; }
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
@@ -518,7 +532,7 @@ public sealed class GameCreationWizard
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
private static string? NextAfterDuration(WizardPayload p)
{
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Format;
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Capacity;
return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime;
}
private static string? NextAfterVisibility(WizardPayload p)
@@ -530,6 +544,7 @@ public sealed class GameCreationWizard
return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish;
}
private static (string? sys, int? dur) SplitSystemDuration(string s)
{
var idx = s.IndexOf(':');
@@ -50,6 +50,8 @@ public sealed class WizardPayload
public sealed class WizardPoolInput
{
public int? MaxPlayers { get; set; }
public List<WizardSlotInput> Slots { get; set; } = new();
}
@@ -224,6 +224,7 @@ public static class WizardStepViewBuilder
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
AppendFormatLocation(sb, p);
if (p.Pool?.MaxPlayers is { } poolMax) sb.AppendLine($"👥 Мест в пуле: {poolMax}");
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
sb.AppendLine();
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
@@ -5,7 +5,17 @@ using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
public sealed record SessionListItemDto(
Guid Id,
string Title,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
int PlayerCount,
int WaitlistCount,
bool CanManage,
bool IsUserActive,
bool IsUserWaitlisted);
public sealed record SessionListResult(
IReadOnlyList<SessionListItemDto> Sessions,
@@ -29,7 +39,27 @@ public sealed class ListSessionsHandler(
WHERE gm.group_id = s.group_id
AND manager_player.platform = @Platform
AND manager_player.external_user_id = @ExternalUserId
) AS CanManage
) AS CanManage,
EXISTS (
SELECT 1
FROM session_participants user_sp
JOIN players user_p ON user_p.id = user_sp.player_id
WHERE user_sp.session_id = s.id
AND user_sp.is_gm = false
AND user_sp.registration_status = @Active
AND user_p.platform = @Platform
AND user_p.external_user_id = @ExternalUserId
) AS IsUserActive,
EXISTS (
SELECT 1
FROM session_participants user_sp
JOIN players user_p ON user_p.id = user_sp.player_id
WHERE user_sp.session_id = s.id
AND user_sp.is_gm = false
AND user_sp.registration_status = @Waitlisted
AND user_p.platform = @Platform
AND user_p.external_user_id = @ExternalUserId
) AS IsUserWaitlisted
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
@@ -82,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.11.0</div>
<div class="nav-version">v3.11.1</div>
</div>
</Authorized>
<NotAuthorized>
@@ -1,225 +0,0 @@
using GmRelay.DiscordBot.Features.Sessions;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordNewSessionHandlerTests
{
private static string GetRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Directory.GetParent(dir)?.FullName;
}
return dir ?? throw new InvalidOperationException("Could not find repo root");
}
// --- Runtime tests for ParseTimeInput (static, no DB) ---
[Fact]
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
{
var future = DateTimeOffset.UtcNow.AddDays(7);
var result = DiscordNewSessionHandler.ParseTimeInput(
future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
// 15:00 MSK = 12:00 UTC
Assert.Equal(12, result.Value.Hour);
Assert.Equal(0, result.Value.Minute);
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
}
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
var expected = FutureDateAt1930();
var result = DiscordNewSessionHandler.ParseTimeInput(
expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
// Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
Assert.Equal(16, result.Value.Hour);
Assert.Equal(30, result.Value.Minute);
}
[Fact]
public void ParseTimeInput_ShouldRejectPastDate()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00");
Assert.False(result.IsSuccess);
}
[Fact]
public void ParseTimeInput_ShouldParseRussianDateFormat()
{
var expected = FutureDateAt1930();
var result = DiscordNewSessionHandler.ParseTimeInput(
expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
}
[Fact]
public void ParseTimeInput_ShouldRejectInvalidFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date");
Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}
// --- Source-level structural tests ---
[Fact]
public void Handler_ShouldExist()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
Assert.True(File.Exists(handlerPath), "DiscordNewSessionHandler should exist.");
}
[Fact]
public void Handler_ShouldUseDapperForDatabaseAccess()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("QueryAsync", source, StringComparison.Ordinal);
Assert.Contains("ExecuteAsync", source, StringComparison.Ordinal);
Assert.Contains("ExecuteScalarAsync", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldUseNpgsqlDataSource()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("NpgsqlDataSource", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldCheckPermissionsViaPermissionChecker()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("CanManageSchedule", source, StringComparison.Ordinal);
Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldLoadCoGmPermissionsFromDiscordPlayers()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Matches(
@"QueryAsync<ulong>[\s\S]*JOIN players p ON p\.id = gm\.player_id[\s\S]*p\.platform = 'Discord'[\s\S]*g\.external_group_id = @GuildId",
source);
}
[Fact]
public void Handler_ShouldBePlatformNeutral()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.DoesNotContain("telegram_chat_id", source, StringComparison.Ordinal);
Assert.DoesNotContain("telegram_id", source, StringComparison.Ordinal);
Assert.Contains("platform = 'Discord'", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldUseTransactions()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("BeginTransactionAsync", source, StringComparison.Ordinal);
Assert.Contains("CommitAsync", source, StringComparison.Ordinal);
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldNotRollbackCommittedTransactionAfterPostCommitFailure()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("transactionCommitted = false", source, StringComparison.Ordinal);
Assert.Contains("transactionCommitted = true", source, StringComparison.Ordinal);
Assert.Contains("if (!transactionCommitted)", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldRespectCancellationToken()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("CancellationToken", source, StringComparison.Ordinal);
}
[Fact]
public void Command_ShouldRenderEmbedOnSuccess()
{
var repoRoot = GetRepoRoot();
var commandPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionCommand.cs");
var source = File.ReadAllText(commandPath);
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldLeaveScheduleMessageCreationToInteractionResponse()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.DoesNotContain("SendScheduleAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain("PlatformScheduleMessage", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldStoreReadableDiscordGroupNameForWebCards()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("groupName", source, StringComparison.Ordinal);
Assert.Contains("displayGroupName", source, StringComparison.Ordinal);
Assert.Contains("VALUES (@GroupName, 'Discord'", source, StringComparison.Ordinal);
}
private static DateTimeOffset FutureDateAt1930()
{
var future = DateTimeOffset.UtcNow.AddDays(7);
return new DateTimeOffset(
future.Year,
future.Month,
future.Day,
19,
30,
0,
TimeSpan.Zero);
}
}
@@ -2,6 +2,7 @@ using System;
using System.IO;
using System.Reflection;
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Features.Sessions.Wizard;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.Bot.Tests.Discord;
@@ -54,7 +55,6 @@ public sealed class DiscordStartupTests
}
[Theory]
[InlineData(typeof(DiscordNewSessionCommand), "newsession")]
[InlineData(typeof(DiscordListSessionsCommand), "listsessions")]
[InlineData(typeof(DiscordRescheduleCommand), "reschedule")]
public void DiscordSessionSlashCommands_ShouldBeDeclaredOnModuleMethods(Type moduleType, string commandName)
@@ -76,15 +76,28 @@ public sealed class DiscordStartupTests
{
var service = new ApplicationCommandService<SlashCommandContext>();
service.AddModules(typeof(DiscordNewSessionCommand).Assembly);
service.AddModules(typeof(DiscordListSessionsCommand).Assembly);
var commandNames = service.GetCommands()
.Select(command => command.Name)
.ToArray();
Assert.Contains("listsessions", commandNames);
Assert.Contains("reschedule", commandNames);
}
[Fact]
public void DiscordSessionSlashCommands_ShouldIncludeNewSessionWizard()
{
var service = new ApplicationCommandService<SlashCommandContext>();
service.AddModules(typeof(DiscordWizardCommand).Assembly);
var commandNames = service.GetCommands()
.Select(command => command.Name)
.ToArray();
Assert.Contains("newsession", commandNames);
Assert.Contains("listsessions", commandNames);
Assert.Contains("reschedule", commandNames);
}
[Fact]
@@ -114,7 +127,6 @@ public sealed class DiscordStartupTests
{
var program = ReadProgram();
Assert.Contains("DiscordListSessionsHandler", program);
Assert.Contains("DiscordNewSessionHandler", program);
Assert.Contains("JoinSessionHandler", program);
Assert.Contains("LeaveSessionHandler", program);
Assert.Contains("DiscordPermissionChecker", program);
@@ -0,0 +1,77 @@
using GmRelay.DiscordBot.Features.Sessions;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordTimeParserTests
{
[Fact]
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
{
var future = DateTimeOffset.UtcNow.AddDays(7);
var result = DiscordTimeParser.ParseTimeInput(
future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
// 15:00 MSK = 12:00 UTC
Assert.Equal(12, result.Value.Hour);
Assert.Equal(0, result.Value.Minute);
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
}
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
var expected = FutureDateAt1930();
var result = DiscordTimeParser.ParseTimeInput(
expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
// Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
Assert.Equal(16, result.Value.Hour);
Assert.Equal(30, result.Value.Minute);
}
[Fact]
public void ParseTimeInput_ShouldRejectPastDate()
{
var result = DiscordTimeParser.ParseTimeInput("2020-01-01 00:00");
Assert.False(result.IsSuccess);
}
[Fact]
public void ParseTimeInput_ShouldParseRussianDateFormat()
{
var expected = FutureDateAt1930();
var result = DiscordTimeParser.ParseTimeInput(
expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
}
[Fact]
public void ParseTimeInput_ShouldRejectInvalidFormat()
{
var result = DiscordTimeParser.ParseTimeInput("not-a-date");
Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}
private static DateTimeOffset FutureDateAt1930()
{
var future = DateTimeOffset.UtcNow.AddDays(7);
return new DateTimeOffset(
future.Year,
future.Month,
future.Day,
19,
30,
0,
TimeSpan.Zero);
}
}
@@ -56,6 +56,29 @@ public sealed class DiscordWizardStepCapacityRenderTests
.Select(b => b.Label ?? string.Empty)
.ToList();
[Fact]
public void RenderFormat_ContainsOnlineAndOfflineButtons()
{
var draft = new WizardDraft { Step = WizardStepNames.Format };
var render = DiscordWizardStep.Render(draft, new WizardPayload());
var labels = ExtractButtonLabels(render);
Assert.Contains(labels, l => l.Contains("Online", System.StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Offline", System.StringComparison.Ordinal));
}
[Theory]
[InlineData(WizardSessionFormat.Online, "🔗 Ссылка")]
[InlineData(WizardSessionFormat.Offline, "📍 Адрес")]
public void RenderLocation_ForFormat_OpensModalAndShowsPrompt(WizardSessionFormat format, string expectedTitle)
{
var draft = new WizardDraft { Step = WizardStepNames.Location };
var render = DiscordWizardStep.Render(draft, new WizardPayload { Format = format });
Assert.Equal(expectedTitle, render.EmbedTitle);
Assert.Equal(WizardStepNames.Location, render.OpenModalStep);
}
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
DiscordWizardStep.DiscordWizardRender render) =>
render.Components
@@ -82,4 +82,117 @@ public sealed class DiscordWizardSubmitterBuildCommandTests
Assert.Equal(5, cmd.MaxPlayers);
}
[Fact]
public void BuildCommand_WhenFormatIsOnline_PropagatesFormatAndJoinLink()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = 5,
},
};
var cmd = DiscordWizardSubmitter.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal("Online", cmd.Format);
Assert.Equal("https://vtt.example/game", cmd.Link);
Assert.Null(cmd.LocationAddress);
}
[Fact]
public void BuildCommand_WhenFormatIsOffline_PropagatesFormatAndAddress()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Format = WizardSessionFormat.Offline,
LocationAddress = "Москва, ул. Кубиков, 12",
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = 5,
},
};
var cmd = DiscordWizardSubmitter.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal("Offline", cmd.Format);
Assert.Equal("Москва, ул. Кубиков, 12", cmd.LocationAddress);
Assert.Equal(string.Empty, cmd.Link);
}
[Fact]
public void BuildCommand_WhenPoolMaxPlayersIsSet_PropagatesValueToMaxPlayers()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var slotTime = DateTimeOffset.UtcNow.AddDays(1);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Format = WizardSessionFormat.Online,
JoinLink = "https://vtt.example/game",
Pool = new WizardPoolInput
{
MaxPlayers = 12,
Slots = { new WizardSlotInput { ScheduledAt = slotTime, MaxPlayers = 8 } },
},
};
var cmd = DiscordWizardSubmitter.BuildCommand(
draft,
payload,
new[] { slotTime },
payload.Pool.MaxPlayers,
isOneShot: false);
Assert.Equal(12, cmd.MaxPlayers);
}
}
@@ -46,7 +46,7 @@ public sealed class GameCreationWizardStepTransitionsTests
}
[Fact]
public async Task PoolSystemDuration_PreselectedButton_AdvancesToFormat()
public async Task PoolSystemDuration_PreselectedButton_AdvancesToCapacity()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
@@ -60,7 +60,7 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Format, draft.Step);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("system", out var sys));
@@ -69,6 +69,95 @@ public sealed class GameCreationWizardStepTransitionsTests
Assert.Equal(240, dur.GetInt32());
}
[Fact]
public async Task PoolCapacity_Text_AdvancesToFormat_AndSetsMaxPlayers()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
};
var draft = NewDraft(WizardStepNames.Capacity, payload);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("10", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Format, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("pool", out var pool));
Assert.True(pool.TryGetProperty("maxPlayers", out var maxPlayers));
Assert.Equal(10, maxPlayers.GetInt32());
}
[Fact]
public async Task PoolSystemDuration_FreeTextDuration_AdvancesToCapacity()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
};
var draft = NewDraft(WizardStepNames.PoolSystemDuration, payload);
drafts.Seed(draft);
await wizard.HandleInteractionAsync(TextInteraction("4", ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("durationMinutes", out var dur));
Assert.Equal(240, dur.GetInt32());
}
[Fact]
public async Task Back_FromPoolCapacity_GoesToPoolSystemDuration()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
};
var draft = NewDraft(WizardStepNames.Capacity, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
}
[Theory]
[InlineData("waitlist:on")]
[InlineData("waitlist:off")]
public async Task Capacity_WithMaxPlayersAndWaitlist_AdvancesToFormat(string waitlistChoice)
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1), MaxPlayers = 5 },
};
var draft = NewDraft(WizardStepNames.Capacity, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, waitlistChoice);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Format, draft.Step);
}
[Fact]
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
{
@@ -20,7 +20,9 @@ public sealed class SessionListMessageRendererTests
4,
3,
1,
true)
true,
false,
false)
};
var text = SessionListMessageRenderer.RenderText(sessions);
@@ -32,25 +34,92 @@ public sealed class SessionListMessageRendererTests
Assert.Contains(actions, a => a.Payload == $"reschedule_session:{sessionId}");
Assert.Contains(actions, a => a.Payload == $"promote_waitlist:{sessionId}");
Assert.Contains(actions, a => a.Payload == $"delete_session:{sessionId}");
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
Assert.Contains(actions, a => a.Label == $"❌ Отменить {shortDate}");
Assert.Contains(actions, a => a.Label == $"⏰ Перенести {shortDate}");
Assert.Contains(actions, a => a.Label == $"⬆️ С ожидания {shortDate}");
Assert.Contains(actions, a => a.Label == $"🗑 Удалить {shortDate}");
}
[Fact]
public void Render_ShouldHideManagerActions_WhenUserCannotManage()
public void Render_ShouldIncludeJoinAction_WhenPlayerIsNotRegistered()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionListItemDto(
Guid.NewGuid(),
sessionId,
"Ravenloft",
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
SessionStatus.Planned,
4,
3,
0,
false,
false,
false)
};
var actions = SessionListMessageRenderer.RenderActions(sessions);
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
Assert.Single(actions);
Assert.Contains(actions, a => a.Payload == $"join_session:{sessionId}");
Assert.Contains(actions, a => a.Label == $"✅ Записаться {shortDate}");
}
[Fact]
public void Render_ShouldIncludeLeaveAction_WhenPlayerIsActive()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionListItemDto(
sessionId,
"Ravenloft",
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
SessionStatus.Planned,
4,
3,
0,
false,
true,
false)
};
var actions = SessionListMessageRenderer.RenderActions(sessions);
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
Assert.Single(actions);
Assert.Contains(actions, a => a.Payload == $"leave_session:{sessionId}");
Assert.Contains(actions, a => a.Label == $"✖️ Выйти {shortDate}");
}
[Fact]
public void Render_ShouldIncludeLeaveWaitlistAction_WhenPlayerIsWaitlisted()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionListItemDto(
sessionId,
"Ravenloft",
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
SessionStatus.Planned,
4,
3,
1,
false)
false,
false,
true)
};
var actions = SessionListMessageRenderer.RenderActions(sessions);
Assert.Empty(actions);
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
Assert.Single(actions);
Assert.Contains(actions, a => a.Payload == $"leave_session:{sessionId}");
Assert.Contains(actions, a => a.Label == $"✖️ Выйти из ожидания {shortDate}");
}
}