Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0602052ea | |||
| 9709d09b15 | |||
| a391c51761 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.11.0
|
||||
VERSION: 3.11.1
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+91
-2
@@ -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()
|
||||
{
|
||||
|
||||
+68
-5
@@ -20,7 +20,9 @@ public sealed class SessionListMessageRendererTests
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
true)
|
||||
true,
|
||||
false,
|
||||
false)
|
||||
};
|
||||
|
||||
var text = SessionListMessageRenderer.RenderText(sessions);
|
||||
@@ -41,22 +43,83 @@ public sealed class SessionListMessageRendererTests
|
||||
}
|
||||
|
||||
[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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user