From b577378e9819d54ac1722c5971d1df2732096ccf Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 14:20:14 +0200 Subject: [PATCH 1/6] feat(3c-ii): add ServerResolver for slash command server targeting --- .../CommandLocalizationCatalog.cs | 6 ++ .../Servers/ServerResolution.cs | 7 ++ .../Servers/ServerResolver.cs | 50 ++++++++++++ .../Servers/ServerResolverTests.cs | 78 +++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Servers/ServerResolution.cs create mode 100644 src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs index 80e5c703..cdccaf48 100644 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs @@ -38,6 +38,9 @@ internal sealed class CommandLocalizationCatalog ["command.prox.member"] = "{0} {1}m", ["command.prox.selfunknown"] = "Can't locate you.", ["command.prox.alone"] = "No teammates nearby.", + ["command.server.none"] = "No server is set up yet.", + ["command.server.specify"] = "Multiple servers are set up — choose one with the server option.", + ["command.server.unknown"] = "That server isn't set up.", ["help.title"] = "Commands", ["help.group.control"] = "Control", ["help.group.server"] = "Server", @@ -96,6 +99,9 @@ internal sealed class CommandLocalizationCatalog ["command.prox.member"] = "{0} {1}m", ["command.prox.selfunknown"] = "Impossible de vous localiser.", ["command.prox.alone"] = "Aucun coéquipier à proximité.", + ["command.server.none"] = "Aucun serveur n'est encore configuré.", + ["command.server.specify"] = "Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur.", + ["command.server.unknown"] = "Ce serveur n'est pas configuré.", ["help.title"] = "Commandes", ["help.group.control"] = "Contrôle", ["help.group.server"] = "Serveur", diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerResolution.cs b/src/RustPlusBot.Features.Commands/Servers/ServerResolution.cs new file mode 100644 index 00000000..0eed93fe --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Servers/ServerResolution.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Features.Commands.Servers; + +/// The outcome of resolving a slash command's target server. +/// The resolved server id, or null on error. +/// The resolved server name, or null on error. +/// A localized error to show instead, or null on success. +internal sealed record ServerResolution(Guid? ServerId, string? Name, string? ErrorMessage); diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs b/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs new file mode 100644 index 00000000..e5a0cced --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs @@ -0,0 +1,50 @@ +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Commands.Servers; + +/// Picks the target server for a slash command from an optional (autocompleted) server argument. +/// The guild's server list. +/// Resolves the error messages. +internal sealed class ServerResolver(IServerService servers, ICommandLocalizer localizer) +{ + /// Resolves the target server, or returns a localized error. + /// The owning guild snowflake. + /// The raw server argument (a server id string) or null. + /// The guild culture. + /// A cancellation token. + /// The resolved server, or an error message. + public async Task 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)); + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs new file mode 100644 index 00000000..c203c5a9 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs @@ -0,0 +1,78 @@ +using NSubstitute; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Commands.Servers; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Commands.Tests.Servers; + +public sealed class ServerResolverTests +{ + private static readonly CommandLocalizer Loc = new(CommandLocalizationCatalog.Default); + + private static RustServer Server(Guid id, string name) => new() { Id = id, Name = name, GuildId = 1UL }; + + private static ServerResolver Build(params RustServer[] servers) + { + var svc = Substitute.For(); + svc.ListAsync(1UL, Arg.Any()).Returns(servers); + return new ServerResolver(svc, Loc); + } + + [Fact] + public async Task ReturnsError_WhenNoServers() + { + var r = await Build().ResolveAsync(1UL, null, "en", CancellationToken.None); + Assert.Null(r.ServerId); + Assert.Equal("No server is set up yet.", r.ErrorMessage); + } + + [Fact] + public async Task DefaultsToSingleServer_IgnoringArg() + { + var id = Guid.NewGuid(); + var r = await Build(Server(id, "Main")).ResolveAsync(1UL, null, "en", CancellationToken.None); + Assert.Equal(id, r.ServerId); + Assert.Equal("Main", r.Name); + Assert.Null(r.ErrorMessage); + } + + [Fact] + public async Task ReturnsSpecifyError_WhenManyAndNoArg() + { + var r = await Build(Server(Guid.NewGuid(), "A"), Server(Guid.NewGuid(), "B")) + .ResolveAsync(1UL, null, "en", CancellationToken.None); + Assert.Null(r.ServerId); + Assert.Equal("Multiple servers are set up — choose one with the server option.", r.ErrorMessage); + } + + [Fact] + public async Task ResolvesExplicitArg_WhenMany() + { + var a = Guid.NewGuid(); + var b = Guid.NewGuid(); + var r = await Build(Server(a, "A"), Server(b, "B")) + .ResolveAsync(1UL, b.ToString(), "en", CancellationToken.None); + Assert.Equal(b, r.ServerId); + Assert.Equal("B", r.Name); + Assert.Null(r.ErrorMessage); + } + + [Fact] + public async Task ReturnsUnknownError_WhenArgNotRegistered() + { + var r = await Build(Server(Guid.NewGuid(), "A")) + .ResolveAsync(1UL, Guid.NewGuid().ToString(), "en", CancellationToken.None); + Assert.Null(r.ServerId); + Assert.Equal("That server isn't set up.", r.ErrorMessage); + } + + [Fact] + public async Task ReturnsUnknownError_WhenArgUnparseable() + { + var r = await Build(Server(Guid.NewGuid(), "A"), Server(Guid.NewGuid(), "B")) + .ResolveAsync(1UL, "not-a-guid", "en", CancellationToken.None); + Assert.Null(r.ServerId); + Assert.Equal("That server isn't set up.", r.ErrorMessage); + } +} From e12c73fcfe941506335a4964c0b57da5e7158703 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 14:24:07 +0200 Subject: [PATCH 2/6] feat(3c-ii): add ServerQueryService adapter over in-game handlers Co-Authored-By: Claude Opus 4.8 --- .../Servers/ServerQueryService.cs | 43 +++++++++++++++++ .../Servers/ServerQueryServiceTests.cs | 48 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs b/src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs new file mode 100644 index 00000000..7e452c6d --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs @@ -0,0 +1,43 @@ +using RustPlusBot.Features.Commands.Dispatching; + +namespace RustPlusBot.Features.Commands.Servers; + +/// +/// 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. +/// +internal sealed class ServerQueryService +{ + private readonly Dictionary _handlers; + + /// Initializes the service from the registered handlers. + /// The available in-game command handlers. + public ServerQueryService(IEnumerable handlers) + { + ArgumentNullException.ThrowIfNull(handlers); + _handlers = handlers.ToDictionary(h => h.Name, StringComparer.Ordinal); + } + + /// Runs the named handler against a server and returns its localized reply. + /// The handler name (e.g. "pop"). + /// The owning guild snowflake. + /// The target server id. + /// The guild culture. + /// A cancellation token. + /// The reply text, or null if the command is unknown or the handler produced no reply. + public async Task 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); + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs new file mode 100644 index 00000000..96a64e4a --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs @@ -0,0 +1,48 @@ +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Servers; + +namespace RustPlusBot.Features.Commands.Tests.Servers; + +public sealed class ServerQueryServiceTests +{ + private sealed class StubHandler(string name, string? reply) : ICommandHandler + { + public CommandContext? Seen { get; private set; } + public string Name => name; + + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + Seen = context; + return Task.FromResult(reply); + } + } + + [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); + } +} From c354d2f5a41c9d6570d5d3f395c9797ae1ecb84d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 14:29:52 +0200 Subject: [PATCH 3/6] feat(3c-ii): add /pop /time /wipe /online /offline /team /alive slash commands --- .../CommandServiceCollectionExtensions.cs | 3 + .../Modules/ServerCommandModule.cs | 116 ++++++++++++++++++ .../Servers/ServerAutocompleteHandler.cs | 37 ++++++ .../CommandRegistrationTests.cs | 6 + 4 files changed, 162 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs create mode 100644 src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs index 2b4d542b..c57a0bc6 100644 --- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using RustPlusBot.Features.Commands.Hosting; using RustPlusBot.Features.Commands.Leader; using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Commands.Servers; namespace RustPlusBot.Features.Commands; @@ -44,6 +45,8 @@ public static IServiceCollection AddCommands(this IServiceCollection services) // Discord surfaces (3c): help renderer, leader service, and the interaction modules. services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddSingleton(new InteractionModuleAssembly(typeof(CommandServiceCollectionExtensions).Assembly)); return services; diff --git a/src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs b/src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs new file mode 100644 index 00000000..b8c91bd0 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs @@ -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; + +/// Read-only live-data slash commands (/pop, /time, /wipe, /online, /offline, /team, /alive). +/// Creates a short-lived DI scope per interaction. +/// The logger. +public sealed partial class ServerCommandModule( + IServiceScopeFactory scopeFactory, + ILogger logger) + : InteractionModuleBase +{ + /// Shows the current player count. + /// The target server (only needed if more than one is registered). + [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); + + /// Shows the in-game time. + /// The target server (only needed if more than one is registered). + [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); + + /// Shows how long ago the server wiped. + /// The target server (only needed if more than one is registered). + [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); + + /// Lists online teammates. + /// The target server (only needed if more than one is registered). + [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); + + /// Lists offline teammates. + /// The target server (only needed if more than one is registered). + [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); + + /// Lists all team members. + /// The target server (only needed if more than one is registered). + [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); + + /// Shows how long each teammate has survived. + /// The target server (only needed if more than one is registered). + [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(); + var resolver = scope.ServiceProvider.GetRequiredService(); + var query = scope.ServiceProvider.GetRequiredService(); + + 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); +} diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs b/src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs new file mode 100644 index 00000000..b6954183 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs @@ -0,0 +1,37 @@ +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Commands.Servers; + +/// Offers the guild's registered servers (by name) as choices for the slash server option. +public sealed class ServerAutocompleteHandler : AutocompleteHandler +{ + /// + public override async Task GenerateSuggestionsAsync( + IInteractionContext context, + IAutocompleteInteraction autocompleteInteraction, + IParameterInfo parameter, + IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(services); + + if (context.Guild is null) + { + return AutocompletionResult.FromSuccess(); + } + + var scope = services.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var servers = scope.ServiceProvider.GetRequiredService(); + var known = await servers.ListAsync(context.Guild.Id).ConfigureAwait(false); + var results = known + .Take(25) + .Select(s => new AutocompleteResult(s.Name, s.Id.ToString())); + return AutocompletionResult.FromSuccess(results); + } + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 726abe8c..98a49c72 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -7,8 +7,10 @@ using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Hosting; using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Commands.Servers; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Persistence.Commands; +using RustPlusBot.Persistence.Servers; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Commands.Tests; @@ -26,6 +28,7 @@ public void Dispatcher_and_handlers_resolve() services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); services.AddOptions(); services.AddCommands(); @@ -51,6 +54,8 @@ public void Dispatcher_and_handlers_resolve() Assert.Contains(handlers, h => h.Name == "steamid"); Assert.Contains(handlers, h => h.Name == "alive"); Assert.Contains(handlers, h => h.Name == "prox"); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } [Fact] @@ -64,6 +69,7 @@ public void Commands_contribute_an_interaction_module_assembly() services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); services.AddOptions(); services.AddCommands(); From 6394e9da1d6a8da5a480e6ef26564bb35853af6d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 14:33:34 +0200 Subject: [PATCH 4/6] chore(3c-ii): apply jb ReformatAndReorder Co-Authored-By: Claude Opus 4.8 --- .../CommandLocalizationCatalog.cs | 3 ++- .../Servers/ServerQueryServiceTests.cs | 24 +++++++++---------- .../Servers/ServerResolverTests.cs | 5 +++- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs index cdccaf48..345fb547 100644 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs @@ -100,7 +100,8 @@ internal sealed class CommandLocalizationCatalog ["command.prox.selfunknown"] = "Impossible de vous localiser.", ["command.prox.alone"] = "Aucun coéquipier à proximité.", ["command.server.none"] = "Aucun serveur n'est encore configuré.", - ["command.server.specify"] = "Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur.", + ["command.server.specify"] = + "Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur.", ["command.server.unknown"] = "Ce serveur n'est pas configuré.", ["help.title"] = "Commandes", ["help.group.control"] = "Contrôle", diff --git a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs index 96a64e4a..3b9e846e 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerQueryServiceTests.cs @@ -5,18 +5,6 @@ namespace RustPlusBot.Features.Commands.Tests.Servers; public sealed class ServerQueryServiceTests { - private sealed class StubHandler(string name, string? reply) : ICommandHandler - { - public CommandContext? Seen { get; private set; } - public string Name => name; - - public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) - { - Seen = context; - return Task.FromResult(reply); - } - } - [Fact] public async Task RunsMatchingHandler_AndReturnsReply() { @@ -45,4 +33,16 @@ public async Task ReturnsNull_WhenNoHandlerMatches() Assert.Null(reply); } + + private sealed class StubHandler(string name, string? reply) : ICommandHandler + { + public CommandContext? Seen { get; private set; } + public string Name => name; + + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + Seen = context; + return Task.FromResult(reply); + } + } } diff --git a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs index c203c5a9..0852773b 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs @@ -10,7 +10,10 @@ public sealed class ServerResolverTests { private static readonly CommandLocalizer Loc = new(CommandLocalizationCatalog.Default); - private static RustServer Server(Guid id, string name) => new() { Id = id, Name = name, GuildId = 1UL }; + private static RustServer Server(Guid id, string name) => new() + { + Id = id, Name = name, GuildId = 1UL + }; private static ServerResolver Build(params RustServer[] servers) { From 676572d9efc5e05fbf72bb1e75457e0b5e271cf2 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 14:38:09 +0200 Subject: [PATCH 5/6] fix(3c-ii): filter autocomplete by typed input + pin wipe handler in reg test Address final whole-branch review minors: ServerAutocompleteHandler now filters the guild's servers by the user's in-progress text (case-insensitive) before the 25-cap, so a server past the 25th alphabetically is reachable; CommandRegistrationTests now asserts the 'wipe' handler is registered (the count tripwire alone wouldn't catch a rename of the newly-surfaced /wipe). Co-Authored-By: Claude Opus 4.8 --- .../Servers/ServerAutocompleteHandler.cs | 3 +++ .../CommandRegistrationTests.cs | 1 + 2 files changed, 4 insertions(+) diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs b/src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs index b6954183..32c46897 100644 --- a/src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs +++ b/src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs @@ -23,12 +23,15 @@ public override async Task GenerateSuggestionsAsync( 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(); 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); diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 98a49c72..4ae52d83 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -48,6 +48,7 @@ public void Dispatcher_and_handlers_resolve() Assert.Contains(handlers, h => h.Name == "mute"); Assert.Contains(handlers, h => h.Name == "pop"); Assert.Contains(handlers, h => h.Name == "time"); + Assert.Contains(handlers, h => h.Name == "wipe"); Assert.Contains(handlers, h => h.Name == "online"); Assert.Contains(handlers, h => h.Name == "offline"); Assert.Contains(handlers, h => h.Name == "team"); From f4986f669aa4af8e5ee2a149e2545a19432a70c9 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 15:00:14 +0200 Subject: [PATCH 6/6] fix(3c-ii): address Copilot review on PR #12 - ServerResolver ctor XML doc: 'servers' is the IServerService, not a list. - Rename ServerResolver test DefaultsToSingleServer_IgnoringArg -> DefaultsToSingleServer_WhenNoArg: it passes a null arg, so the name now matches what it covers. (The single-server + explicit-non-matching-arg -> 'unknown' path is already covered by ReturnsUnknownError_WhenArgNotRegistered.) Co-Authored-By: Claude Opus 4.8 --- src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs | 2 +- .../Servers/ServerResolverTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs b/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs index e5a0cced..f49855b2 100644 --- a/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs +++ b/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs @@ -4,7 +4,7 @@ namespace RustPlusBot.Features.Commands.Servers; /// Picks the target server for a slash command from an optional (autocompleted) server argument. -/// The guild's server list. +/// The server service used to list the guild's servers. /// Resolves the error messages. internal sealed class ServerResolver(IServerService servers, ICommandLocalizer localizer) { diff --git a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs index 0852773b..e8c0afad 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs @@ -31,7 +31,7 @@ public async Task ReturnsError_WhenNoServers() } [Fact] - public async Task DefaultsToSingleServer_IgnoringArg() + public async Task DefaultsToSingleServer_WhenNoArg() { var id = Guid.NewGuid(); var r = await Build(Server(id, "Main")).ResolveAsync(1UL, null, "en", CancellationToken.None);