feat(discord): make /newsession identical to Telegram wizard
PR Checks / test-and-build (pull_request) Successful in 30m45s
PR Checks / test-and-build (pull_request) Successful in 30m45s
- Remove legacy DiscordNewSessionCommand/Handler and their tests. - Rename /newsession-wizard to /newsession. - Add shared pool capacity step before Format/Location. - Render Format and Location in Discord wizard; Location uses a modal. - Propagate Format, JoinLink and LocationAddress in BuildCommand. - Publish created sessions through existing IPlatformMessenger pipeline. - Update README, version bump to 3.11.1, sync compose/deploy/NavMenu.
This commit is contained in:
@@ -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),
|
||||
"📅 Дата/время слота",
|
||||
|
||||
@@ -12,6 +12,7 @@ using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
@@ -31,19 +32,25 @@ public sealed class DiscordWizardSubmitter
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly IWizardContextStore _contextStore;
|
||||
private readonly ILogger<DiscordWizardSubmitter> _log;
|
||||
private readonly IPlatformMessenger? _platformMessenger;
|
||||
private readonly NpgsqlDataSource? _dataSource;
|
||||
|
||||
public DiscordWizardSubmitter(
|
||||
CreateSessionHandler shared,
|
||||
RestClient rest,
|
||||
IWizardDraftRepository drafts,
|
||||
IWizardContextStore contextStore,
|
||||
ILogger<DiscordWizardSubmitter> log)
|
||||
ILogger<DiscordWizardSubmitter> log,
|
||||
IPlatformMessenger? platformMessenger = null,
|
||||
NpgsqlDataSource? dataSource = null)
|
||||
{
|
||||
_shared = shared;
|
||||
_rest = rest;
|
||||
_drafts = drafts;
|
||||
_contextStore = contextStore;
|
||||
_log = log;
|
||||
_platformMessenger = platformMessenger;
|
||||
_dataSource = dataSource;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -65,21 +72,25 @@ 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);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -90,7 +101,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 +151,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 +175,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 +200,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>();
|
||||
|
||||
Reference in New Issue
Block a user