Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a59c48348 | |||
| fa506b2aef | |||
| e0602052ea | |||
| 9709d09b15 | |||
| a391c51761 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.11.0
|
VERSION: 3.11.3
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.11.0</Version>
|
<Version>3.11.3</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v3.10.0`.
|
**Текущая версия:** `v3.11.1`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах.
|
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах.
|
||||||
|
|
||||||
### Discord Bot
|
### 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.
|
- **Кнопки Join/Leave с ephemeral-ответами**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
|
||||||
- **RSVP (подтверждения) за 24ч до сессии**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает итоги RSVP.
|
- **RSVP (подтверждения) за 24ч до сессии**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает итоги RSVP.
|
||||||
- **DM-напоминания за 1ч и ссылки перед игрой**: one-hour reminders и join-link notifications отправляются в Discord DM при включённых личных уведомлениях; сбои DM логируются без публичного fallback.
|
- **DM-напоминания за 1ч и ссылки перед игрой**: one-hour reminders и join-link notifications отправляются в Discord DM при включённых личных уведомлениях; сбои DM логируются без публичного fallback.
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.3
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.0
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.3
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -86,7 +86,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.3
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -23,11 +23,18 @@ internal static class SessionListMessageRenderer
|
|||||||
|
|
||||||
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
|
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||||
{
|
{
|
||||||
if (sessions.Count == 0 || !sessions.First().CanManage)
|
if (sessions.Count == 0)
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return sessions.First().CanManage
|
||||||
|
? RenderManagerActions(sessions)
|
||||||
|
: RenderPlayerActions(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PlatformMessageAction> RenderManagerActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||||
|
{
|
||||||
var actions = new List<PlatformMessageAction>();
|
var actions = new List<PlatformMessageAction>();
|
||||||
|
|
||||||
foreach (var session in sessions)
|
foreach (var session in sessions)
|
||||||
@@ -60,4 +67,31 @@ internal static class SessionListMessageRenderer
|
|||||||
|
|
||||||
return actions;
|
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>();
|
var parsedOptions = new List<DateTimeOffset>();
|
||||||
foreach (var opt in options)
|
foreach (var opt in options)
|
||||||
{
|
{
|
||||||
var result = DiscordNewSessionHandler.ParseTimeInput(opt);
|
var result = DiscordTimeParser.ParseTimeInput(opt);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
await Context.Interaction.SendResponseAsync(
|
await Context.Interaction.SendResponseAsync(
|
||||||
@@ -85,7 +85,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
|
|||||||
parsedOptions.Add(result.Value);
|
parsedOptions.Add(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline);
|
var deadlineResult = DiscordTimeParser.ParseTimeInput(deadline);
|
||||||
if (!deadlineResult.IsSuccess)
|
if (!deadlineResult.IsSuccess)
|
||||||
{
|
{
|
||||||
await Context.Interaction.SendResponseAsync(
|
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;
|
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Small lookup helper for Discord permission checks. The
|
/// Small lookup helper for Discord permission checks. The slash command
|
||||||
/// <see cref="DiscordNewSessionHandler"/> already runs the same SQL
|
/// and reschedule command both need to enumerate DB managers for a guild;
|
||||||
/// inline; this class is here so the wizard slash command can do the
|
/// this class centralises the query so it isn't duplicated.
|
||||||
/// same check without duplicating the query string.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class DiscordPermissionLookup
|
internal static class DiscordPermissionLookup
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Slash entry point for the Discord wizard. Mirrors the Telegram
|
/// 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
|
/// first invocation, the persisted first-step message is re-shown
|
||||||
/// when the user already has an active draft, and the owner/co-GM
|
/// when the user already has an active draft, and the owner/co-GM
|
||||||
/// permission check from <see cref="DiscordPermissionChecker"/> is
|
/// permission check from <see cref="DiscordPermissionChecker"/> is
|
||||||
@@ -44,7 +44,7 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
|
|||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SlashCommand("newsession-wizard", "Пошаговое создание игры или пула")]
|
[SlashCommand("newsession", "Пошаговое создание игры или пула")]
|
||||||
public async Task ExecuteAsync(
|
public async Task ExecuteAsync(
|
||||||
[SlashCommandParameter(Name = "mode", Description = "Пропустить выбор типа (single/pool)")] string? mode = null)
|
[SlashCommandParameter(Name = "mode", Description = "Пропустить выбор типа (single/pool)")] string? mode = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ public sealed class WizardInteractionDispatcher
|
|||||||
WizardStepNames.Cover,
|
WizardStepNames.Cover,
|
||||||
WizardStepNames.DateTime,
|
WizardStepNames.DateTime,
|
||||||
WizardStepNames.Capacity,
|
WizardStepNames.Capacity,
|
||||||
|
WizardStepNames.Location,
|
||||||
WizardStepNames.PoolSlotDateTime,
|
WizardStepNames.PoolSlotDateTime,
|
||||||
WizardStepNames.PoolSlotCapacity,
|
WizardStepNames.PoolSlotCapacity,
|
||||||
"SystemFreeText",
|
"SystemFreeText",
|
||||||
@@ -166,7 +167,7 @@ public sealed class WizardInteractionDispatcher
|
|||||||
{
|
{
|
||||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||||
new InteractionMessageProperties()
|
new InteractionMessageProperties()
|
||||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
|
||||||
.WithFlags(MessageFlags.Ephemeral)));
|
.WithFlags(MessageFlags.Ephemeral)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -276,14 +277,14 @@ public sealed class WizardInteractionDispatcher
|
|||||||
// itself doesn't know about resume, so we just edit the
|
// itself doesn't know about resume, so we just edit the
|
||||||
// draft message via the messenger).
|
// draft message via the messenger).
|
||||||
// resume:restart → delete the draft and prompt the user to
|
// resume:restart → delete the draft and prompt the user to
|
||||||
// re-run /newsession-wizard.
|
// re-run /newsession.
|
||||||
if (parts.Length >= 3 && parts[2] == "restart")
|
if (parts.Length >= 3 && parts[2] == "restart")
|
||||||
{
|
{
|
||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
_contextStore.Remove(draft.Id);
|
_contextStore.Remove(draft.Id);
|
||||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||||
new InteractionMessageProperties()
|
new InteractionMessageProperties()
|
||||||
.WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.")
|
.WithContent("♻️ Мастер сброшен. Запустите /newsession заново.")
|
||||||
.WithFlags(MessageFlags.Ephemeral)));
|
.WithFlags(MessageFlags.Ephemeral)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -314,7 +315,7 @@ public sealed class WizardInteractionDispatcher
|
|||||||
{
|
{
|
||||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||||
new InteractionMessageProperties()
|
new InteractionMessageProperties()
|
||||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
|
||||||
.WithFlags(MessageFlags.Ephemeral)));
|
.WithFlags(MessageFlags.Ephemeral)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -365,7 +366,7 @@ public sealed class WizardInteractionDispatcher
|
|||||||
{
|
{
|
||||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||||
new InteractionMessageProperties()
|
new InteractionMessageProperties()
|
||||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
.WithContent("📭 Нет активного мастера. Запустите /newsession.")
|
||||||
.WithFlags(MessageFlags.Ephemeral)));
|
.WithFlags(MessageFlags.Ephemeral)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ public static class DiscordWizardStep
|
|||||||
WizardStepNames.Duration => RenderDuration(),
|
WizardStepNames.Duration => RenderDuration(),
|
||||||
WizardStepNames.DateTime => RenderDateTime(),
|
WizardStepNames.DateTime => RenderDateTime(),
|
||||||
WizardStepNames.Capacity => RenderCapacity(),
|
WizardStepNames.Capacity => RenderCapacity(),
|
||||||
|
WizardStepNames.Format => RenderFormat(),
|
||||||
|
WizardStepNames.Location => RenderLocation(payload),
|
||||||
WizardStepNames.Visibility => RenderVisibility(),
|
WizardStepNames.Visibility => RenderVisibility(),
|
||||||
WizardStepNames.PickClub => RenderPickClub(clubs ?? System.Array.Empty<WizardClubOption>()),
|
WizardStepNames.PickClub => RenderPickClub(clubs ?? System.Array.Empty<WizardClubOption>()),
|
||||||
WizardStepNames.Publish => RenderPublish(),
|
WizardStepNames.Publish => RenderPublish(),
|
||||||
@@ -263,6 +265,29 @@ public static class DiscordWizardStep
|
|||||||
},
|
},
|
||||||
OpenModalStep: WizardStepNames.Capacity);
|
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(
|
private static DiscordWizardRender RenderVisibility() => new(
|
||||||
"🔒 Видимость",
|
"🔒 Видимость",
|
||||||
"Выберите, кто увидит сессию.",
|
"Выберите, кто увидит сессию.",
|
||||||
@@ -566,6 +591,20 @@ public static class DiscordWizardStep
|
|||||||
Required = true,
|
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(
|
WizardStepNames.PoolSlotDateTime => new ModalProperties(
|
||||||
ModalCustomId(WizardStepNames.PoolSlotDateTime),
|
ModalCustomId(WizardStepNames.PoolSlotDateTime),
|
||||||
"📅 Дата/время слота",
|
"📅 Дата/время слота",
|
||||||
|
|||||||
@@ -65,19 +65,32 @@ public sealed class DiscordWizardSubmitter
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var commands = BuildCommands(draft, payload);
|
var commands = BuildCommands(draft, payload);
|
||||||
foreach (var cmd in commands)
|
foreach (var cmd in commands)
|
||||||
{
|
{
|
||||||
await _shared.HandleAsync(cmd, ct);
|
var result = await _shared.HandleAsync(cmd, ct);
|
||||||
}
|
if (!result.Success)
|
||||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
{
|
||||||
await EditDraftMessageAsync(
|
await EditDraftMessageAsync(
|
||||||
draft,
|
draft,
|
||||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
|
||||||
Array.Empty<WizardAction>(),
|
Array.Empty<WizardAction>(),
|
||||||
ct);
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
created.Add((cmd, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
_contextStore.Remove(draft.Id);
|
_contextStore.Remove(draft.Id);
|
||||||
}
|
}
|
||||||
@@ -90,7 +103,7 @@ public sealed class DiscordWizardSubmitter
|
|||||||
{
|
{
|
||||||
await EditDraftMessageAsync(
|
await EditDraftMessageAsync(
|
||||||
draft,
|
draft,
|
||||||
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession-wizard, чтобы начать заново.",
|
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
|
||||||
Array.Empty<WizardAction>(),
|
Array.Empty<WizardAction>(),
|
||||||
ct);
|
ct);
|
||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
@@ -140,7 +153,7 @@ public sealed class DiscordWizardSubmitter
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
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(
|
internal static CreateSessionCommand BuildCommand(
|
||||||
WizardDraft draft,
|
WizardDraft draft,
|
||||||
@@ -164,15 +177,16 @@ public sealed class DiscordWizardSubmitter
|
|||||||
User: user,
|
User: user,
|
||||||
Group: group,
|
Group: group,
|
||||||
Title: p.Title ?? string.Empty,
|
Title: p.Title ?? string.Empty,
|
||||||
Link: string.Empty,
|
Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty,
|
||||||
ScheduledTimes: scheduledTimes,
|
ScheduledTimes: scheduledTimes,
|
||||||
MaxPlayers: maxPlayers,
|
MaxPlayers: maxPlayers,
|
||||||
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
||||||
System: ParseSystem(p.System),
|
System: ParseSystem(p.System),
|
||||||
Description: p.Description,
|
Description: p.Description,
|
||||||
Format: null,
|
Format: p.Format?.ToString(),
|
||||||
DurationMinutes: p.DurationMinutes,
|
DurationMinutes: p.DurationMinutes,
|
||||||
IsOneShot: isOneShot);
|
IsOneShot: isOneShot,
|
||||||
|
LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GameSystem? ParseSystem(string? code)
|
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.Title)) missingFields.Add("название");
|
||||||
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
|
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
|
||||||
if (!p.DurationMinutes.HasValue) 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.Visibility is null) missingFields.Add("видимость");
|
||||||
|
|
||||||
if (p.Type == WizardCreationType.Single)
|
if (p.Type == WizardCreationType.Single)
|
||||||
{
|
{
|
||||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||||||
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
|
// MaxPlayers = null is a valid "♾ Без лимита" choice.
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
|||||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
||||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||||
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
|
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
|
||||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
|
||||||
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||||
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
||||||
|
|||||||
@@ -224,7 +224,16 @@ public sealed class GameCreationWizard
|
|||||||
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
|
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
|
||||||
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", 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
|
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
|
||||||
? (WizardStepNames.Format, SetMaxPlayers(payload, cap), payload)
|
? (WizardStepNames.Format, SetMaxPlayers(payload, cap), payload)
|
||||||
: (null, "Лимит должен быть 1..50", payload);
|
: (null, "Лимит должен быть 1..50", payload);
|
||||||
@@ -247,7 +256,7 @@ public sealed class GameCreationWizard
|
|||||||
|
|
||||||
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
|
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
|
||||||
return TryParseHours(input, out var pdur)
|
return TryParseHours(input, out var pdur)
|
||||||
? (WizardStepNames.Format, SetDurationMinutes(payload, pdur), payload)
|
? (WizardStepNames.Capacity, SetDurationMinutes(payload, pdur), payload)
|
||||||
: (null, "Неверная длительность (1..12 ч)", payload);
|
: (null, "Неверная длительность (1..12 ч)", payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSlotDateTime:
|
case WizardStepNames.PoolSlotDateTime:
|
||||||
@@ -314,10 +323,12 @@ public sealed class GameCreationWizard
|
|||||||
{
|
{
|
||||||
if (choice is "no_limit")
|
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, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
|
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
|
||||||
}
|
}
|
||||||
@@ -368,11 +379,12 @@ public sealed class GameCreationWizard
|
|||||||
{
|
{
|
||||||
"_custom" => (WizardStepNames.PoolSystemDuration, null),
|
"_custom" => (WizardStepNames.PoolSystemDuration, null),
|
||||||
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
|
{ } 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, "Неверный выбор"),
|
||||||
_ => (null, "Неизвестный выбор"),
|
_ => (null, "Неизвестный выбор"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch
|
private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch
|
||||||
{
|
{
|
||||||
"add" => BeginNewPoolSlot(p),
|
"add" => BeginNewPoolSlot(p),
|
||||||
@@ -409,8 +421,8 @@ public sealed class GameCreationWizard
|
|||||||
WizardStepNames.System => WizardStepNames.Cover,
|
WizardStepNames.System => WizardStepNames.Cover,
|
||||||
WizardStepNames.Duration => WizardStepNames.System,
|
WizardStepNames.Duration => WizardStepNames.System,
|
||||||
WizardStepNames.DateTime => WizardStepNames.Duration,
|
WizardStepNames.DateTime => WizardStepNames.Duration,
|
||||||
WizardStepNames.Capacity => WizardStepNames.DateTime,
|
WizardStepNames.Capacity => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.DateTime,
|
||||||
WizardStepNames.Format => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.Capacity,
|
WizardStepNames.Format => WizardStepNames.Capacity,
|
||||||
WizardStepNames.Location => WizardStepNames.Format,
|
WizardStepNames.Location => WizardStepNames.Format,
|
||||||
WizardStepNames.Visibility => WizardStepNames.Location,
|
WizardStepNames.Visibility => WizardStepNames.Location,
|
||||||
WizardStepNames.PickClub => WizardStepNames.Visibility,
|
WizardStepNames.PickClub => WizardStepNames.Visibility,
|
||||||
@@ -458,6 +470,8 @@ public sealed class GameCreationWizard
|
|||||||
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
||||||
private static string? SetMaxPlayers(WizardPayload p, int? v)
|
private static string? SetMaxPlayers(WizardPayload p, int? v)
|
||||||
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
|
{ 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? 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? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
|
||||||
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = 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? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
|
||||||
private static string? NextAfterDuration(WizardPayload p)
|
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;
|
return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime;
|
||||||
}
|
}
|
||||||
private static string? NextAfterVisibility(WizardPayload p)
|
private static string? NextAfterVisibility(WizardPayload p)
|
||||||
@@ -530,6 +544,7 @@ public sealed class GameCreationWizard
|
|||||||
return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish;
|
return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static (string? sys, int? dur) SplitSystemDuration(string s)
|
private static (string? sys, int? dur) SplitSystemDuration(string s)
|
||||||
{
|
{
|
||||||
var idx = s.IndexOf(':');
|
var idx = s.IndexOf(':');
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ public sealed class WizardPayload
|
|||||||
|
|
||||||
public sealed class WizardPoolInput
|
public sealed class WizardPoolInput
|
||||||
{
|
{
|
||||||
|
public int? MaxPlayers { get; set; }
|
||||||
|
|
||||||
public List<WizardSlotInput> Slots { get; set; } = new();
|
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 (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||||
AppendFormatLocation(sb, p);
|
AppendFormatLocation(sb, p);
|
||||||
|
if (p.Pool?.MaxPlayers is { } poolMax) sb.AppendLine($"👥 Мест в пуле: {poolMax}");
|
||||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
|
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
|
||||||
|
|||||||
@@ -5,7 +5,17 @@ using Npgsql;
|
|||||||
|
|
||||||
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
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(
|
public sealed record SessionListResult(
|
||||||
IReadOnlyList<SessionListItemDto> Sessions,
|
IReadOnlyList<SessionListItemDto> Sessions,
|
||||||
@@ -29,7 +39,27 @@ public sealed class ListSessionsHandler(
|
|||||||
WHERE gm.group_id = s.group_id
|
WHERE gm.group_id = s.group_id
|
||||||
AND manager_player.platform = @Platform
|
AND manager_player.platform = @Platform
|
||||||
AND manager_player.external_user_id = @ExternalUserId
|
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
|
FROM sessions s
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.11.0</div>
|
<div class="nav-version">v3.11.3</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<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.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using GmRelay.DiscordBot.Features.Sessions;
|
using GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||||
using NetCord.Services.ApplicationCommands;
|
using NetCord.Services.ApplicationCommands;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Discord;
|
namespace GmRelay.Bot.Tests.Discord;
|
||||||
@@ -54,7 +55,6 @@ public sealed class DiscordStartupTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(typeof(DiscordNewSessionCommand), "newsession")]
|
|
||||||
[InlineData(typeof(DiscordListSessionsCommand), "listsessions")]
|
[InlineData(typeof(DiscordListSessionsCommand), "listsessions")]
|
||||||
[InlineData(typeof(DiscordRescheduleCommand), "reschedule")]
|
[InlineData(typeof(DiscordRescheduleCommand), "reschedule")]
|
||||||
public void DiscordSessionSlashCommands_ShouldBeDeclaredOnModuleMethods(Type moduleType, string commandName)
|
public void DiscordSessionSlashCommands_ShouldBeDeclaredOnModuleMethods(Type moduleType, string commandName)
|
||||||
@@ -76,15 +76,28 @@ public sealed class DiscordStartupTests
|
|||||||
{
|
{
|
||||||
var service = new ApplicationCommandService<SlashCommandContext>();
|
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()
|
var commandNames = service.GetCommands()
|
||||||
.Select(command => command.Name)
|
.Select(command => command.Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
Assert.Contains("newsession", commandNames);
|
Assert.Contains("newsession", commandNames);
|
||||||
Assert.Contains("listsessions", commandNames);
|
|
||||||
Assert.Contains("reschedule", commandNames);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -114,7 +127,6 @@ public sealed class DiscordStartupTests
|
|||||||
{
|
{
|
||||||
var program = ReadProgram();
|
var program = ReadProgram();
|
||||||
Assert.Contains("DiscordListSessionsHandler", program);
|
Assert.Contains("DiscordListSessionsHandler", program);
|
||||||
Assert.Contains("DiscordNewSessionHandler", program);
|
|
||||||
Assert.Contains("JoinSessionHandler", program);
|
Assert.Contains("JoinSessionHandler", program);
|
||||||
Assert.Contains("LeaveSessionHandler", program);
|
Assert.Contains("LeaveSessionHandler", program);
|
||||||
Assert.Contains("DiscordPermissionChecker", 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)
|
.Select(b => b.Label ?? string.Empty)
|
||||||
.ToList();
|
.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(
|
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
|
||||||
DiscordWizardStep.DiscordWizardRender render) =>
|
DiscordWizardStep.DiscordWizardRender render) =>
|
||||||
render.Components
|
render.Components
|
||||||
|
|||||||
@@ -82,4 +82,117 @@ public sealed class DiscordWizardSubmitterBuildCommandTests
|
|||||||
|
|
||||||
Assert.Equal(5, cmd.MaxPlayers);
|
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]
|
[Fact]
|
||||||
public async Task PoolSystemDuration_PreselectedButton_AdvancesToFormat()
|
public async Task PoolSystemDuration_PreselectedButton_AdvancesToCapacity()
|
||||||
{
|
{
|
||||||
var wizard = BuildWizard(out var drafts, out _);
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
var payload = new WizardPayload
|
var payload = new WizardPayload
|
||||||
@@ -60,7 +60,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
|
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
|
||||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
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);
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
Assert.True(root.TryGetProperty("system", out var sys));
|
Assert.True(root.TryGetProperty("system", out var sys));
|
||||||
@@ -69,6 +69,95 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
Assert.Equal(240, dur.GetInt32());
|
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]
|
[Fact]
|
||||||
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
|
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
|
||||||
{
|
{
|
||||||
|
|||||||
+68
-5
@@ -20,7 +20,9 @@ public sealed class SessionListMessageRendererTests
|
|||||||
4,
|
4,
|
||||||
3,
|
3,
|
||||||
1,
|
1,
|
||||||
true)
|
true,
|
||||||
|
false,
|
||||||
|
false)
|
||||||
};
|
};
|
||||||
|
|
||||||
var text = SessionListMessageRenderer.RenderText(sessions);
|
var text = SessionListMessageRenderer.RenderText(sessions);
|
||||||
@@ -41,22 +43,83 @@ public sealed class SessionListMessageRendererTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Render_ShouldHideManagerActions_WhenUserCannotManage()
|
public void Render_ShouldIncludeJoinAction_WhenPlayerIsNotRegistered()
|
||||||
{
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
var sessions = new[]
|
var sessions = new[]
|
||||||
{
|
{
|
||||||
new SessionListItemDto(
|
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",
|
"Ravenloft",
|
||||||
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
||||||
SessionStatus.Planned,
|
SessionStatus.Planned,
|
||||||
4,
|
4,
|
||||||
3,
|
3,
|
||||||
1,
|
1,
|
||||||
false)
|
false,
|
||||||
|
false,
|
||||||
|
true)
|
||||||
};
|
};
|
||||||
|
|
||||||
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
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