feat(discord): enable session join leave buttons
PR Checks / test-and-build (pull_request) Successful in 6m6s
PR Checks / test-and-build (pull_request) Successful in 6m6s
Move neutral join/leave handlers into GmRelay.Shared so Telegram and Discord share capacity, waitlist, duplicate-click, and schedule-update behavior. Add Discord component routing for join_session and leave_session buttons with deferred ephemeral replies and serialized schedule message updates. Bump version to 2.5.0 and update Discord docs. Refs #29
This commit is contained in:
@@ -7,9 +7,13 @@ namespace GmRelay.Bot.Tests.Discord;
|
||||
public sealed class DiscordPlatformMessengerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ShouldAcceptRestClient()
|
||||
public void Constructor_ShouldAcceptRestClientAndReplyCache()
|
||||
{
|
||||
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[] { typeof(NetCord.Rest.RestClient) });
|
||||
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[]
|
||||
{
|
||||
typeof(NetCord.Rest.RestClient),
|
||||
typeof(DiscordInteractionReplyCache)
|
||||
});
|
||||
Assert.NotNull(constructor);
|
||||
}
|
||||
|
||||
@@ -18,4 +22,30 @@ public sealed class DiscordPlatformMessengerTests
|
||||
{
|
||||
Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnswerInteractionAsync_ShouldStoreReplyForComponentModule()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs");
|
||||
|
||||
Assert.Contains("DiscordInteractionReplyCache", source, StringComparison.Ordinal);
|
||||
Assert.Contains("interactionReplies.Store(reply)", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests
|
||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||
|
||||
Assert.Contains("gmrelay-discord-bot:2.4.0", compose);
|
||||
Assert.Contains("gmrelay-discord-bot:2.5.0", compose);
|
||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||
@@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
|
||||
Assert.Contains("<Version>2.4.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 2.4.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:2.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:2.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:2.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("<Version>2.5.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 2.5.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains(
|
||||
"v2.4.0",
|
||||
"v2.5.0",
|
||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
public sealed class DiscordSessionInteractionMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParseCustomId_WhenActionAndSessionIdMatch_ReturnsSessionId()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
|
||||
var result = DiscordSessionInteractionMapper.TryParseCustomId(
|
||||
$"join_session:{sessionId}",
|
||||
"join_session",
|
||||
out var parsedSessionId);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(sessionId, parsedSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseCustomId_WhenActionDoesNotMatch_ReturnsFalse()
|
||||
{
|
||||
var result = DiscordSessionInteractionMapper.TryParseCustomId(
|
||||
$"leave_session:{Guid.NewGuid()}",
|
||||
"join_session",
|
||||
out _);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseCustomId_WhenSessionIdIsInvalid_ReturnsFalse()
|
||||
{
|
||||
var result = DiscordSessionInteractionMapper.TryParseCustomId(
|
||||
"join_session:not-a-guid",
|
||||
"join_session",
|
||||
out _);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateJoinCommand_ShouldBuildPlatformNeutralDiscordCommand()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var input = CreateInput(sessionId, displayName: "Alice GM");
|
||||
|
||||
JoinSessionCommand command = DiscordSessionInteractionMapper.CreateJoinCommand(input);
|
||||
|
||||
Assert.Equal(sessionId, command.SessionId);
|
||||
Assert.Equal("interaction-1", command.InteractionId);
|
||||
Assert.Equal(PlatformKind.Discord, command.User.Platform);
|
||||
Assert.Equal("42", command.User.ExternalUserId);
|
||||
Assert.Equal("Alice GM", command.User.DisplayName);
|
||||
Assert.Equal("alice", command.User.ExternalUsername);
|
||||
Assert.Equal(PlatformKind.Discord, command.Group.Platform);
|
||||
Assert.Equal("guild-1", command.Group.ExternalGroupId);
|
||||
Assert.Equal("channel-1", command.Group.ExternalChannelId);
|
||||
Assert.Equal(PlatformKind.Discord, command.ScheduleMessage.Platform);
|
||||
Assert.Equal("guild-1", command.ScheduleMessage.ExternalGroupId);
|
||||
Assert.Equal("message-1", command.ScheduleMessage.ExternalMessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateLeaveCommand_ShouldBuildPlatformNeutralDiscordCommand()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var input = CreateInput(sessionId, displayName: null);
|
||||
|
||||
LeaveSessionCommand command = DiscordSessionInteractionMapper.CreateLeaveCommand(input);
|
||||
|
||||
Assert.Equal(sessionId, command.SessionId);
|
||||
Assert.Equal("interaction-1", command.InteractionId);
|
||||
Assert.Equal(PlatformKind.Discord, command.User.Platform);
|
||||
Assert.Equal("42", command.User.ExternalUserId);
|
||||
Assert.Equal("alice", command.User.DisplayName);
|
||||
Assert.Equal("alice", command.User.ExternalUsername);
|
||||
Assert.Equal("guild-1", command.Group.ExternalGroupId);
|
||||
Assert.Equal("channel-1", command.Group.ExternalChannelId);
|
||||
Assert.Equal("message-1", command.ScheduleMessage.ExternalMessageId);
|
||||
}
|
||||
|
||||
private static DiscordSessionInteractionInput CreateInput(Guid sessionId, string? displayName)
|
||||
=> new(
|
||||
SessionId: sessionId,
|
||||
InteractionId: "interaction-1",
|
||||
GuildId: "guild-1",
|
||||
ChannelId: "channel-1",
|
||||
MessageId: "message-1",
|
||||
UserId: 42,
|
||||
Username: "alice",
|
||||
DisplayName: displayName);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
public sealed class DiscordSessionInteractionModuleSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Module_ShouldRouteJoinAndLeaveButtonsToNeutralHandlers()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
|
||||
|
||||
Assert.Contains("ComponentInteractionModule<ButtonInteractionContext>", source, StringComparison.Ordinal);
|
||||
Assert.Contains("[ComponentInteraction(\"join_session\")]", source, StringComparison.Ordinal);
|
||||
Assert.Contains("[ComponentInteraction(\"leave_session\")]", source, StringComparison.Ordinal);
|
||||
Assert.Contains("JoinSessionHandler", source, StringComparison.Ordinal);
|
||||
Assert.Contains("LeaveSessionHandler", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DiscordSessionInteractionMapper.CreateJoinCommand", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DiscordSessionInteractionMapper.CreateLeaveCommand", source, StringComparison.Ordinal);
|
||||
Assert.Contains("RespondAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ModifyResponseAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("Не удалось обработать кнопку.", source, StringComparison.Ordinal);
|
||||
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,8 @@ public sealed class DiscordStartupTests
|
||||
var program = ReadProgram();
|
||||
Assert.Contains("DiscordListSessionsHandler", program);
|
||||
Assert.Contains("DiscordNewSessionHandler", program);
|
||||
Assert.Contains("JoinSessionHandler", program);
|
||||
Assert.Contains("LeaveSessionHandler", program);
|
||||
Assert.Contains("DiscordPermissionChecker", program);
|
||||
Assert.Contains("DiscordPlatformMessenger", program);
|
||||
Assert.Contains("IPlatformMessenger", program);
|
||||
|
||||
Reference in New Issue
Block a user