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/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs index 80e5c703..345fb547 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,10 @@ 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/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..32c46897 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Servers/ServerAutocompleteHandler.cs @@ -0,0 +1,40 @@ +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 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/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/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..f49855b2 --- /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 server service used to list the guild's servers. +/// 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/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 726abe8c..4ae52d83 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(); @@ -45,12 +48,15 @@ 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"); 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 +70,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(); 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..3b9e846e --- /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 +{ + [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 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 new file mode 100644 index 00000000..e8c0afad --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs @@ -0,0 +1,81 @@ +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_WhenNoArg() + { + 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); + } +}