Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -44,6 +45,8 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
// Discord surfaces (3c): help renderer, leader service, and the interaction modules.
services.AddScoped<HelpEmbedRenderer>();
services.AddScoped<LeaderService>();
services.AddScoped<ServerResolver>();
services.AddScoped<ServerQueryService>();
services.AddSingleton(new InteractionModuleAssembly(typeof(CommandServiceCollectionExtensions).Assembly));

return services;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
116 changes: 116 additions & 0 deletions src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs
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);
}
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 src/RustPlusBot.Features.Commands/Servers/ServerQueryService.cs
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 src/RustPlusBot.Features.Commands/Servers/ServerResolution.cs
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 src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs
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 server service used to list the guild's servers.</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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +28,7 @@ public void Dispatcher_and_handlers_resolve()
services.AddSingleton<IRustServerQuery>(Substitute.For<IRustServerQuery>());
services.AddScoped<IMuteStore>(_ => Substitute.For<IMuteStore>());
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
services.AddScoped<IServerService>(_ => Substitute.For<IServerService>());
services.AddOptions<CommandOptions>();
services.AddCommands();

Expand All @@ -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<ServerResolver>());
Assert.NotNull(scope.ServiceProvider.GetRequiredService<ServerQueryService>());
}

[Fact]
Expand All @@ -64,6 +70,7 @@ public void Commands_contribute_an_interaction_module_assembly()
services.AddSingleton<IRustServerQuery>(Substitute.For<IRustServerQuery>());
services.AddScoped<IMuteStore>(_ => Substitute.For<IMuteStore>());
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
services.AddScoped<IServerService>(_ => Substitute.For<IServerService>());
services.AddOptions<CommandOptions>();
services.AddCommands();

Expand Down
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);
}
}
}
Loading
Loading