-
Notifications
You must be signed in to change notification settings - Fork 0
Subsystem 3c-ii: in-game data commands as Discord slash commands (/pop /time /wipe /online /offline /team /alive) #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
b577378
feat(3c-ii): add ServerResolver for slash command server targeting
HandyS11 e12c73f
feat(3c-ii): add ServerQueryService adapter over in-game handlers
HandyS11 c354d2f
feat(3c-ii): add /pop /time /wipe /online /offline /team /alive slash…
HandyS11 6394e9d
chore(3c-ii): apply jb ReformatAndReorder
HandyS11 676572d
fix(3c-ii): filter autocomplete by typed input + pin wipe handler in …
HandyS11 f4986f6
fix(3c-ii): address Copilot review on PR #12
HandyS11 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
116 changes: 116 additions & 0 deletions
116
src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| using Discord.Interactions; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Microsoft.Extensions.Logging; | ||
| using RustPlusBot.Features.Commands.Servers; | ||
| using RustPlusBot.Persistence.Workspace; | ||
|
|
||
| namespace RustPlusBot.Features.Commands.Modules; | ||
|
|
||
| /// <summary>Read-only live-data slash commands (/pop, /time, /wipe, /online, /offline, /team, /alive).</summary> | ||
| /// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param> | ||
| /// <param name="logger">The logger.</param> | ||
| public sealed partial class ServerCommandModule( | ||
| IServiceScopeFactory scopeFactory, | ||
| ILogger<ServerCommandModule> logger) | ||
| : InteractionModuleBase<SocketInteractionContext> | ||
| { | ||
| /// <summary>Shows the current player count.</summary> | ||
| /// <param name="server">The target server (only needed if more than one is registered).</param> | ||
| [SlashCommand("pop", "Show the current player count")] | ||
| public Task PopAsync( | ||
| [Summary("server", "Which server (only needed if more than one)")] | ||
| [Autocomplete(typeof(ServerAutocompleteHandler))] | ||
| string? server = null) => RunAsync("pop", server); | ||
|
|
||
| /// <summary>Shows the in-game time.</summary> | ||
| /// <param name="server">The target server (only needed if more than one is registered).</param> | ||
| [SlashCommand("time", "Show the in-game time")] | ||
| public Task TimeAsync( | ||
| [Summary("server", "Which server (only needed if more than one)")] | ||
| [Autocomplete(typeof(ServerAutocompleteHandler))] | ||
| string? server = null) => RunAsync("time", server); | ||
|
|
||
| /// <summary>Shows how long ago the server wiped.</summary> | ||
| /// <param name="server">The target server (only needed if more than one is registered).</param> | ||
| [SlashCommand("wipe", "Show how long ago the server wiped")] | ||
| public Task WipeAsync( | ||
| [Summary("server", "Which server (only needed if more than one)")] | ||
| [Autocomplete(typeof(ServerAutocompleteHandler))] | ||
| string? server = null) => RunAsync("wipe", server); | ||
|
|
||
| /// <summary>Lists online teammates.</summary> | ||
| /// <param name="server">The target server (only needed if more than one is registered).</param> | ||
| [SlashCommand("online", "List online teammates")] | ||
| public Task OnlineAsync( | ||
| [Summary("server", "Which server (only needed if more than one)")] | ||
| [Autocomplete(typeof(ServerAutocompleteHandler))] | ||
| string? server = null) => RunAsync("online", server); | ||
|
|
||
| /// <summary>Lists offline teammates.</summary> | ||
| /// <param name="server">The target server (only needed if more than one is registered).</param> | ||
| [SlashCommand("offline", "List offline teammates")] | ||
| public Task OfflineAsync( | ||
| [Summary("server", "Which server (only needed if more than one)")] | ||
| [Autocomplete(typeof(ServerAutocompleteHandler))] | ||
| string? server = null) => RunAsync("offline", server); | ||
|
|
||
| /// <summary>Lists all team members.</summary> | ||
| /// <param name="server">The target server (only needed if more than one is registered).</param> | ||
| [SlashCommand("team", "List all team members")] | ||
| public Task TeamAsync( | ||
| [Summary("server", "Which server (only needed if more than one)")] | ||
| [Autocomplete(typeof(ServerAutocompleteHandler))] | ||
| string? server = null) => RunAsync("team", server); | ||
|
|
||
| /// <summary>Shows how long each teammate has survived.</summary> | ||
| /// <param name="server">The target server (only needed if more than one is registered).</param> | ||
| [SlashCommand("alive", "Show how long each teammate has survived")] | ||
| public Task AliveAsync( | ||
| [Summary("server", "Which server (only needed if more than one)")] | ||
| [Autocomplete(typeof(ServerAutocompleteHandler))] | ||
| string? server = null) => RunAsync("alive", server); | ||
|
|
||
| private async Task RunAsync(string commandName, string? server) | ||
| { | ||
| if (Context.Guild is null) | ||
| { | ||
| await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); | ||
| return; | ||
| } | ||
|
|
||
| await DeferAsync(ephemeral: true).ConfigureAwait(false); | ||
| try | ||
| { | ||
| var scope = scopeFactory.CreateAsyncScope(); | ||
| await using (scope.ConfigureAwait(false)) | ||
| { | ||
| var workspace = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>(); | ||
| var resolver = scope.ServiceProvider.GetRequiredService<ServerResolver>(); | ||
| var query = scope.ServiceProvider.GetRequiredService<ServerQueryService>(); | ||
|
|
||
| var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); | ||
| var resolution = await resolver.ResolveAsync(Context.Guild.Id, server, culture, CancellationToken.None) | ||
| .ConfigureAwait(false); | ||
| if (resolution.ErrorMessage is { } error) | ||
| { | ||
| await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); | ||
| return; | ||
| } | ||
|
|
||
| var reply = await query.RunAsync(commandName, Context.Guild.Id, resolution.ServerId!.Value, culture, | ||
| CancellationToken.None).ConfigureAwait(false); | ||
| await FollowupAsync(reply ?? "Something went wrong.", ephemeral: true).ConfigureAwait(false); | ||
| } | ||
| } | ||
| #pragma warning disable CA1031 // Broad catch: an interaction failure must not surface as a Discord "interaction failed". | ||
| catch (Exception ex) | ||
| #pragma warning restore CA1031 | ||
| { | ||
| LogCommandFaulted(logger, ex, commandName); | ||
| await FollowupAsync("Something went wrong.", ephemeral: true).ConfigureAwait(false); | ||
| } | ||
| } | ||
|
|
||
| [LoggerMessage(Level = LogLevel.Error, Message = "Slash data command '{Command}' faulted.")] | ||
| private static partial void LogCommandFaulted(ILogger logger, Exception exception, string command); | ||
| } |
40 changes: 40 additions & 0 deletions
40
src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| using Discord; | ||
| using Discord.Interactions; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using RustPlusBot.Persistence.Servers; | ||
|
|
||
| namespace RustPlusBot.Features.Commands.Servers; | ||
|
|
||
| /// <summary>Offers the guild's registered servers (by name) as choices for the slash <c>server</c> option.</summary> | ||
| public sealed class ServerAutocompleteHandler : AutocompleteHandler | ||
| { | ||
| /// <inheritdoc /> | ||
| public override async Task<AutocompletionResult> GenerateSuggestionsAsync( | ||
| IInteractionContext context, | ||
| IAutocompleteInteraction autocompleteInteraction, | ||
| IParameterInfo parameter, | ||
| IServiceProvider services) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(context); | ||
| ArgumentNullException.ThrowIfNull(services); | ||
|
|
||
| if (context.Guild is null) | ||
| { | ||
| return AutocompletionResult.FromSuccess(); | ||
| } | ||
|
|
||
| var typed = autocompleteInteraction.Data.Current.Value as string; | ||
| var scope = services.CreateAsyncScope(); | ||
| await using (scope.ConfigureAwait(false)) | ||
| { | ||
| var servers = scope.ServiceProvider.GetRequiredService<IServerService>(); | ||
| var known = await servers.ListAsync(context.Guild.Id).ConfigureAwait(false); | ||
| var results = known | ||
| .Where(s => string.IsNullOrEmpty(typed) || | ||
| s.Name.Contains(typed, StringComparison.OrdinalIgnoreCase)) | ||
| .Take(25) | ||
| .Select(s => new AutocompleteResult(s.Name, s.Id.ToString())); | ||
| return AutocompletionResult.FromSuccess(results); | ||
| } | ||
| } | ||
| } |
43 changes: 43 additions & 0 deletions
43
src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| using RustPlusBot.Features.Commands.Dispatching; | ||
|
|
||
| namespace RustPlusBot.Features.Commands.Servers; | ||
|
|
||
| /// <summary> | ||
| /// Runs an in-game command handler for the slash-command path. Builds a synthetic context (no in-game | ||
| /// caller, no args) and delegates to the existing handler so slash and in-game replies are identical. | ||
| /// </summary> | ||
| internal sealed class ServerQueryService | ||
| { | ||
| private readonly Dictionary<string, ICommandHandler> _handlers; | ||
|
|
||
| /// <summary>Initializes the service from the registered handlers.</summary> | ||
| /// <param name="handlers">The available in-game command handlers.</param> | ||
| public ServerQueryService(IEnumerable<ICommandHandler> handlers) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(handlers); | ||
| _handlers = handlers.ToDictionary(h => h.Name, StringComparer.Ordinal); | ||
| } | ||
|
|
||
| /// <summary>Runs the named handler against a server and returns its localized reply.</summary> | ||
| /// <param name="commandName">The handler name (e.g. "pop").</param> | ||
| /// <param name="guildId">The owning guild snowflake.</param> | ||
| /// <param name="serverId">The target server id.</param> | ||
| /// <param name="culture">The guild culture.</param> | ||
| /// <param name="cancellationToken">A cancellation token.</param> | ||
| /// <returns>The reply text, or null if the command is unknown or the handler produced no reply.</returns> | ||
| public async Task<string?> RunAsync( | ||
| string commandName, | ||
| ulong guildId, | ||
| Guid serverId, | ||
| string culture, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| if (!_handlers.TryGetValue(commandName, out var handler)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| var context = new CommandContext(guildId, serverId, culture, 0UL, string.Empty, []); | ||
| return await handler.ExecuteAsync(context, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| } |
7 changes: 7 additions & 0 deletions
7
src/RustPlusBot.Features.Commands/Servers/ServerResolution.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| namespace RustPlusBot.Features.Commands.Servers; | ||
|
|
||
| /// <summary>The outcome of resolving a slash command's target server.</summary> | ||
| /// <param name="ServerId">The resolved server id, or null on error.</param> | ||
| /// <param name="Name">The resolved server name, or null on error.</param> | ||
| /// <param name="ErrorMessage">A localized error to show instead, or null on success.</param> | ||
| internal sealed record ServerResolution(Guid? ServerId, string? Name, string? ErrorMessage); |
50 changes: 50 additions & 0 deletions
50
src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| using RustPlusBot.Features.Commands.Localization; | ||
| using RustPlusBot.Persistence.Servers; | ||
|
|
||
| namespace RustPlusBot.Features.Commands.Servers; | ||
|
|
||
| /// <summary>Picks the target server for a slash command from an optional (autocompleted) server argument.</summary> | ||
| /// <param name="servers">The guild's server list.</param> | ||
| /// <param name="localizer">Resolves the error messages.</param> | ||
| internal sealed class ServerResolver(IServerService servers, ICommandLocalizer localizer) | ||
| { | ||
| /// <summary>Resolves the target server, or returns a localized error.</summary> | ||
| /// <param name="guildId">The owning guild snowflake.</param> | ||
| /// <param name="serverArg">The raw server argument (a server id string) or null.</param> | ||
| /// <param name="culture">The guild culture.</param> | ||
| /// <param name="cancellationToken">A cancellation token.</param> | ||
| /// <returns>The resolved server, or an error message.</returns> | ||
| public async Task<ServerResolution> ResolveAsync( | ||
| ulong guildId, | ||
| string? serverArg, | ||
| string culture, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var known = await servers.ListAsync(guildId, cancellationToken).ConfigureAwait(false); | ||
| if (known.Count == 0) | ||
| { | ||
| return new ServerResolution(null, null, localizer.Get("command.server.none", culture)); | ||
| } | ||
|
|
||
| if (string.IsNullOrWhiteSpace(serverArg)) | ||
| { | ||
| if (known.Count == 1) | ||
| { | ||
| return new ServerResolution(known[0].Id, known[0].Name, null); | ||
| } | ||
|
|
||
| return new ServerResolution(null, null, localizer.Get("command.server.specify", culture)); | ||
| } | ||
|
|
||
| if (Guid.TryParse(serverArg, out var id)) | ||
| { | ||
| var match = known.FirstOrDefault(s => s.Id == id); | ||
| if (match is not null) | ||
| { | ||
| return new ServerResolution(match.Id, match.Name, null); | ||
| } | ||
| } | ||
|
|
||
| return new ServerResolution(null, null, localizer.Get("command.server.unknown", culture)); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
48 changes: 48 additions & 0 deletions
48
tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| using RustPlusBot.Features.Commands.Dispatching; | ||
| using RustPlusBot.Features.Commands.Servers; | ||
|
|
||
| namespace RustPlusBot.Features.Commands.Tests.Servers; | ||
|
|
||
| public sealed class ServerQueryServiceTests | ||
| { | ||
| [Fact] | ||
| public async Task RunsMatchingHandler_AndReturnsReply() | ||
| { | ||
| var handler = new StubHandler("pop", "Pop: 5/100 (0 queued)"); | ||
| var service = new ServerQueryService([handler]); | ||
| var server = Guid.NewGuid(); | ||
|
|
||
| var reply = await service.RunAsync("pop", 1UL, server, "en", CancellationToken.None); | ||
|
|
||
| Assert.Equal("Pop: 5/100 (0 queued)", reply); | ||
| Assert.NotNull(handler.Seen); | ||
| Assert.Equal(1UL, handler.Seen!.GuildId); | ||
| Assert.Equal(server, handler.Seen.ServerId); | ||
| Assert.Equal("en", handler.Seen.Culture); | ||
| Assert.Empty(handler.Seen.Args); | ||
| Assert.Equal(0UL, handler.Seen.SenderSteamId); | ||
| Assert.Equal(string.Empty, handler.Seen.SenderName); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task ReturnsNull_WhenNoHandlerMatches() | ||
| { | ||
| var service = new ServerQueryService([new StubHandler("pop", "x")]); | ||
|
|
||
| var reply = await service.RunAsync("time", 1UL, Guid.NewGuid(), "en", CancellationToken.None); | ||
|
|
||
| Assert.Null(reply); | ||
| } | ||
|
|
||
| private sealed class StubHandler(string name, string? reply) : ICommandHandler | ||
| { | ||
| public CommandContext? Seen { get; private set; } | ||
| public string Name => name; | ||
|
|
||
| public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken) | ||
| { | ||
| Seen = context; | ||
| return Task.FromResult(reply); | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.