diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs new file mode 100644 index 0000000..ca31b63 --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs @@ -0,0 +1,62 @@ +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; + +namespace GmRelay.DiscordBot.Features.Sessions; + +[SlashCommand("newsession", "Create a new game session")] +public class DiscordNewSessionCommand : ApplicationCommandModule +{ + private readonly DiscordNewSessionHandler _handler; + + public DiscordNewSessionCommand(DiscordNewSessionHandler handler) + { + _handler = handler; + } + + 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) + { + var guild = Context.Guild + ?? throw new InvalidOperationException("This command can only be used in a guild."); + + var timeResult = DiscordNewSessionHandler.ParseTimeInput(time); + if (!timeResult.IsSuccess) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($"❌ {timeResult.Error}")); + return; + } + + try + { + var view = await _handler.HandleAsync( + guildId: guild.Id.ToString(), + channelId: Context.Channel.Id.ToString(), + userId: Context.User.Id, + userDisplayName: Context.User.GlobalName ?? Context.User.Username, + userRoles: Array.Empty(), // NetCord alpha.489: GuildUser not exposed on SlashCommandContext + guildOwnerId: guild.OwnerId, + title: title, + scheduledAt: timeResult.Value, + maxPlayers: seats is null ? null : (int)seats.Value, + joinLink: link, + CancellationToken.None); + + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message("✅ Сессия создана!")); + } + catch (UnauthorizedAccessException ex) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($"⛔ {ex.Message}")); + } + catch (Exception) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message("💥 Произошла ошибка при создании сессии.")); + } + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs new file mode 100644 index 0000000..f5bdd40 --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs @@ -0,0 +1,148 @@ +using Dapper; +using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; +using GmRelay.Shared.Rendering; +using Npgsql; + +namespace GmRelay.DiscordBot.Features.Sessions; + +public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error); + +public sealed class DiscordNewSessionHandler( + NpgsqlDataSource dataSource, + DiscordPermissionChecker permissionChecker, + IPlatformMessenger messenger, + ILogger logger) +{ + public static TimeParseResult ParseTimeInput(string input) + { + if (DateTimeOffset.TryParseExact( + input.Trim(), + "yyyy-MM-dd HH:mm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal, + out var result)) + { + if (result < DateTimeOffset.UtcNow) + return new TimeParseResult(false, default, "Дата находится в прошлом."); + + return new TimeParseResult(true, result.ToUniversalTime(), null); + } + + if (DateTimeOffset.TryParseExact( + input.Trim(), + "dd.MM.yyyy HH:mm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal, + out var altResult)) + { + if (altResult < DateTimeOffset.UtcNow) + return new TimeParseResult(false, default, "Дата находится в прошлом."); + + return new TimeParseResult(true, altResult.ToUniversalTime(), null); + } + + return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm"); + } + + public async Task HandleAsync( + string guildId, + string channelId, + ulong userId, + string userDisplayName, + IEnumerable userRoles, + ulong guildOwnerId, + string title, + DateTimeOffset scheduledAt, + int? maxPlayers, + string? joinLink, + CancellationToken cancellationToken) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var dbManagerUserIds = await connection.QueryAsync( + @"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 g.external_group_id = @GuildId", + new { GuildId = guildId }); + + if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, userRoles, dbManagerUserIds)) + { + throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии."); + } + + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + 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( + @"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id) + VALUES (@GuildId, '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 { 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( + @"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); + logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId); + + var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) }; + var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty()); + + await messenger.SendScheduleAsync( + new PlatformScheduleMessage( + new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId), + view, + null), + cancellationToken); + + return view; + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } +} diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs new file mode 100644 index 0000000..cc99e36 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs @@ -0,0 +1,43 @@ +using GmRelay.DiscordBot.Features.Sessions; + +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordNewSessionHandlerTests +{ + [Fact] + public void ParseTimeInput_ShouldParseDiscordDateFormat() + { + var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30"); + Assert.True(result.IsSuccess); + Assert.Equal(2026, result.Value.Year); + Assert.Equal(5, result.Value.Month); + Assert.Equal(20, result.Value.Day); + Assert.Equal(19, 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 result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30"); + Assert.True(result.IsSuccess); + Assert.Equal(2026, result.Value.Year); + Assert.Equal(5, result.Value.Month); + Assert.Equal(20, result.Value.Day); + } + + [Fact] + public void ParseTimeInput_ShouldRejectInvalidFormat() + { + var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date"); + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + } +}