Skip to content

Commit b83fe3d

Browse files
HandyS11claude
andauthored
Subsystem 3c-ii: in-game data commands as Discord slash commands (/pop /time /wipe /online /offline /team /alive) (#12)
* feat(3c-ii): add ServerResolver for slash command server targeting * feat(3c-ii): add ServerQueryService adapter over in-game handlers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(3c-ii): add /pop /time /wipe /online /offline /team /alive slash commands * chore(3c-ii): apply jb ReformatAndReorder Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2ec8387 commit b83fe3d

10 files changed

Lines changed: 402 additions & 0 deletions

File tree

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using RustPlusBot.Features.Commands.Hosting;
77
using RustPlusBot.Features.Commands.Leader;
88
using RustPlusBot.Features.Commands.Localization;
9+
using RustPlusBot.Features.Commands.Servers;
910

1011
namespace RustPlusBot.Features.Commands;
1112

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

4952
return services;

src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ internal sealed class CommandLocalizationCatalog
3838
["command.prox.member"] = "{0} {1}m",
3939
["command.prox.selfunknown"] = "Can't locate you.",
4040
["command.prox.alone"] = "No teammates nearby.",
41+
["command.server.none"] = "No server is set up yet.",
42+
["command.server.specify"] = "Multiple servers are set up — choose one with the server option.",
43+
["command.server.unknown"] = "That server isn't set up.",
4144
["help.title"] = "Commands",
4245
["help.group.control"] = "Control",
4346
["help.group.server"] = "Server",
@@ -96,6 +99,10 @@ internal sealed class CommandLocalizationCatalog
9699
["command.prox.member"] = "{0} {1}m",
97100
["command.prox.selfunknown"] = "Impossible de vous localiser.",
98101
["command.prox.alone"] = "Aucun coéquipier à proximité.",
102+
["command.server.none"] = "Aucun serveur n'est encore configuré.",
103+
["command.server.specify"] =
104+
"Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur.",
105+
["command.server.unknown"] = "Ce serveur n'est pas configuré.",
99106
["help.title"] = "Commandes",
100107
["help.group.control"] = "Contrôle",
101108
["help.group.server"] = "Serveur",
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using Discord.Interactions;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Logging;
4+
using RustPlusBot.Features.Commands.Servers;
5+
using RustPlusBot.Persistence.Workspace;
6+
7+
namespace RustPlusBot.Features.Commands.Modules;
8+
9+
/// <summary>Read-only live-data slash commands (/pop, /time, /wipe, /online, /offline, /team, /alive).</summary>
10+
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
11+
/// <param name="logger">The logger.</param>
12+
public sealed partial class ServerCommandModule(
13+
IServiceScopeFactory scopeFactory,
14+
ILogger<ServerCommandModule> logger)
15+
: InteractionModuleBase<SocketInteractionContext>
16+
{
17+
/// <summary>Shows the current player count.</summary>
18+
/// <param name="server">The target server (only needed if more than one is registered).</param>
19+
[SlashCommand("pop", "Show the current player count")]
20+
public Task PopAsync(
21+
[Summary("server", "Which server (only needed if more than one)")]
22+
[Autocomplete(typeof(ServerAutocompleteHandler))]
23+
string? server = null) => RunAsync("pop", server);
24+
25+
/// <summary>Shows the in-game time.</summary>
26+
/// <param name="server">The target server (only needed if more than one is registered).</param>
27+
[SlashCommand("time", "Show the in-game time")]
28+
public Task TimeAsync(
29+
[Summary("server", "Which server (only needed if more than one)")]
30+
[Autocomplete(typeof(ServerAutocompleteHandler))]
31+
string? server = null) => RunAsync("time", server);
32+
33+
/// <summary>Shows how long ago the server wiped.</summary>
34+
/// <param name="server">The target server (only needed if more than one is registered).</param>
35+
[SlashCommand("wipe", "Show how long ago the server wiped")]
36+
public Task WipeAsync(
37+
[Summary("server", "Which server (only needed if more than one)")]
38+
[Autocomplete(typeof(ServerAutocompleteHandler))]
39+
string? server = null) => RunAsync("wipe", server);
40+
41+
/// <summary>Lists online teammates.</summary>
42+
/// <param name="server">The target server (only needed if more than one is registered).</param>
43+
[SlashCommand("online", "List online teammates")]
44+
public Task OnlineAsync(
45+
[Summary("server", "Which server (only needed if more than one)")]
46+
[Autocomplete(typeof(ServerAutocompleteHandler))]
47+
string? server = null) => RunAsync("online", server);
48+
49+
/// <summary>Lists offline teammates.</summary>
50+
/// <param name="server">The target server (only needed if more than one is registered).</param>
51+
[SlashCommand("offline", "List offline teammates")]
52+
public Task OfflineAsync(
53+
[Summary("server", "Which server (only needed if more than one)")]
54+
[Autocomplete(typeof(ServerAutocompleteHandler))]
55+
string? server = null) => RunAsync("offline", server);
56+
57+
/// <summary>Lists all team members.</summary>
58+
/// <param name="server">The target server (only needed if more than one is registered).</param>
59+
[SlashCommand("team", "List all team members")]
60+
public Task TeamAsync(
61+
[Summary("server", "Which server (only needed if more than one)")]
62+
[Autocomplete(typeof(ServerAutocompleteHandler))]
63+
string? server = null) => RunAsync("team", server);
64+
65+
/// <summary>Shows how long each teammate has survived.</summary>
66+
/// <param name="server">The target server (only needed if more than one is registered).</param>
67+
[SlashCommand("alive", "Show how long each teammate has survived")]
68+
public Task AliveAsync(
69+
[Summary("server", "Which server (only needed if more than one)")]
70+
[Autocomplete(typeof(ServerAutocompleteHandler))]
71+
string? server = null) => RunAsync("alive", server);
72+
73+
private async Task RunAsync(string commandName, string? server)
74+
{
75+
if (Context.Guild is null)
76+
{
77+
await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false);
78+
return;
79+
}
80+
81+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
82+
try
83+
{
84+
var scope = scopeFactory.CreateAsyncScope();
85+
await using (scope.ConfigureAwait(false))
86+
{
87+
var workspace = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
88+
var resolver = scope.ServiceProvider.GetRequiredService<ServerResolver>();
89+
var query = scope.ServiceProvider.GetRequiredService<ServerQueryService>();
90+
91+
var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false);
92+
var resolution = await resolver.ResolveAsync(Context.Guild.Id, server, culture, CancellationToken.None)
93+
.ConfigureAwait(false);
94+
if (resolution.ErrorMessage is { } error)
95+
{
96+
await FollowupAsync(error, ephemeral: true).ConfigureAwait(false);
97+
return;
98+
}
99+
100+
var reply = await query.RunAsync(commandName, Context.Guild.Id, resolution.ServerId!.Value, culture,
101+
CancellationToken.None).ConfigureAwait(false);
102+
await FollowupAsync(reply ?? "Something went wrong.", ephemeral: true).ConfigureAwait(false);
103+
}
104+
}
105+
#pragma warning disable CA1031 // Broad catch: an interaction failure must not surface as a Discord "interaction failed".
106+
catch (Exception ex)
107+
#pragma warning restore CA1031
108+
{
109+
LogCommandFaulted(logger, ex, commandName);
110+
await FollowupAsync("Something went wrong.", ephemeral: true).ConfigureAwait(false);
111+
}
112+
}
113+
114+
[LoggerMessage(Level = LogLevel.Error, Message = "Slash data command '{Command}' faulted.")]
115+
private static partial void LogCommandFaulted(ILogger logger, Exception exception, string command);
116+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using Discord;
2+
using Discord.Interactions;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using RustPlusBot.Persistence.Servers;
5+
6+
namespace RustPlusBot.Features.Commands.Servers;
7+
8+
/// <summary>Offers the guild's registered servers (by name) as choices for the slash <c>server</c> option.</summary>
9+
public sealed class ServerAutocompleteHandler : AutocompleteHandler
10+
{
11+
/// <inheritdoc />
12+
public override async Task<AutocompletionResult> GenerateSuggestionsAsync(
13+
IInteractionContext context,
14+
IAutocompleteInteraction autocompleteInteraction,
15+
IParameterInfo parameter,
16+
IServiceProvider services)
17+
{
18+
ArgumentNullException.ThrowIfNull(context);
19+
ArgumentNullException.ThrowIfNull(services);
20+
21+
if (context.Guild is null)
22+
{
23+
return AutocompletionResult.FromSuccess();
24+
}
25+
26+
var typed = autocompleteInteraction.Data.Current.Value as string;
27+
var scope = services.CreateAsyncScope();
28+
await using (scope.ConfigureAwait(false))
29+
{
30+
var servers = scope.ServiceProvider.GetRequiredService<IServerService>();
31+
var known = await servers.ListAsync(context.Guild.Id).ConfigureAwait(false);
32+
var results = known
33+
.Where(s => string.IsNullOrEmpty(typed) ||
34+
s.Name.Contains(typed, StringComparison.OrdinalIgnoreCase))
35+
.Take(25)
36+
.Select(s => new AutocompleteResult(s.Name, s.Id.ToString()));
37+
return AutocompletionResult.FromSuccess(results);
38+
}
39+
}
40+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using RustPlusBot.Features.Commands.Dispatching;
2+
3+
namespace RustPlusBot.Features.Commands.Servers;
4+
5+
/// <summary>
6+
/// Runs an in-game command handler for the slash-command path. Builds a synthetic context (no in-game
7+
/// caller, no args) and delegates to the existing handler so slash and in-game replies are identical.
8+
/// </summary>
9+
internal sealed class ServerQueryService
10+
{
11+
private readonly Dictionary<string, ICommandHandler> _handlers;
12+
13+
/// <summary>Initializes the service from the registered handlers.</summary>
14+
/// <param name="handlers">The available in-game command handlers.</param>
15+
public ServerQueryService(IEnumerable<ICommandHandler> handlers)
16+
{
17+
ArgumentNullException.ThrowIfNull(handlers);
18+
_handlers = handlers.ToDictionary(h => h.Name, StringComparer.Ordinal);
19+
}
20+
21+
/// <summary>Runs the named handler against a server and returns its localized reply.</summary>
22+
/// <param name="commandName">The handler name (e.g. "pop").</param>
23+
/// <param name="guildId">The owning guild snowflake.</param>
24+
/// <param name="serverId">The target server id.</param>
25+
/// <param name="culture">The guild culture.</param>
26+
/// <param name="cancellationToken">A cancellation token.</param>
27+
/// <returns>The reply text, or null if the command is unknown or the handler produced no reply.</returns>
28+
public async Task<string?> RunAsync(
29+
string commandName,
30+
ulong guildId,
31+
Guid serverId,
32+
string culture,
33+
CancellationToken cancellationToken)
34+
{
35+
if (!_handlers.TryGetValue(commandName, out var handler))
36+
{
37+
return null;
38+
}
39+
40+
var context = new CommandContext(guildId, serverId, culture, 0UL, string.Empty, []);
41+
return await handler.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
42+
}
43+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace RustPlusBot.Features.Commands.Servers;
2+
3+
/// <summary>The outcome of resolving a slash command's target server.</summary>
4+
/// <param name="ServerId">The resolved server id, or null on error.</param>
5+
/// <param name="Name">The resolved server name, or null on error.</param>
6+
/// <param name="ErrorMessage">A localized error to show instead, or null on success.</param>
7+
internal sealed record ServerResolution(Guid? ServerId, string? Name, string? ErrorMessage);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using RustPlusBot.Features.Commands.Localization;
2+
using RustPlusBot.Persistence.Servers;
3+
4+
namespace RustPlusBot.Features.Commands.Servers;
5+
6+
/// <summary>Picks the target server for a slash command from an optional (autocompleted) server argument.</summary>
7+
/// <param name="servers">The server service used to list the guild's servers.</param>
8+
/// <param name="localizer">Resolves the error messages.</param>
9+
internal sealed class ServerResolver(IServerService servers, ICommandLocalizer localizer)
10+
{
11+
/// <summary>Resolves the target server, or returns a localized error.</summary>
12+
/// <param name="guildId">The owning guild snowflake.</param>
13+
/// <param name="serverArg">The raw server argument (a server id string) or null.</param>
14+
/// <param name="culture">The guild culture.</param>
15+
/// <param name="cancellationToken">A cancellation token.</param>
16+
/// <returns>The resolved server, or an error message.</returns>
17+
public async Task<ServerResolution> ResolveAsync(
18+
ulong guildId,
19+
string? serverArg,
20+
string culture,
21+
CancellationToken cancellationToken)
22+
{
23+
var known = await servers.ListAsync(guildId, cancellationToken).ConfigureAwait(false);
24+
if (known.Count == 0)
25+
{
26+
return new ServerResolution(null, null, localizer.Get("command.server.none", culture));
27+
}
28+
29+
if (string.IsNullOrWhiteSpace(serverArg))
30+
{
31+
if (known.Count == 1)
32+
{
33+
return new ServerResolution(known[0].Id, known[0].Name, null);
34+
}
35+
36+
return new ServerResolution(null, null, localizer.Get("command.server.specify", culture));
37+
}
38+
39+
if (Guid.TryParse(serverArg, out var id))
40+
{
41+
var match = known.FirstOrDefault(s => s.Id == id);
42+
if (match is not null)
43+
{
44+
return new ServerResolution(match.Id, match.Name, null);
45+
}
46+
}
47+
48+
return new ServerResolution(null, null, localizer.Get("command.server.unknown", culture));
49+
}
50+
}

tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
using RustPlusBot.Features.Commands.Dispatching;
88
using RustPlusBot.Features.Commands.Hosting;
99
using RustPlusBot.Features.Commands.Localization;
10+
using RustPlusBot.Features.Commands.Servers;
1011
using RustPlusBot.Features.Connections.Listening;
1112
using RustPlusBot.Persistence.Commands;
13+
using RustPlusBot.Persistence.Servers;
1214
using RustPlusBot.Persistence.Workspace;
1315

1416
namespace RustPlusBot.Features.Commands.Tests;
@@ -26,6 +28,7 @@ public void Dispatcher_and_handlers_resolve()
2628
services.AddSingleton<IRustServerQuery>(Substitute.For<IRustServerQuery>());
2729
services.AddScoped<IMuteStore>(_ => Substitute.For<IMuteStore>());
2830
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
31+
services.AddScoped<IServerService>(_ => Substitute.For<IServerService>());
2932
services.AddOptions<CommandOptions>();
3033
services.AddCommands();
3134

@@ -45,12 +48,15 @@ public void Dispatcher_and_handlers_resolve()
4548
Assert.Contains(handlers, h => h.Name == "mute");
4649
Assert.Contains(handlers, h => h.Name == "pop");
4750
Assert.Contains(handlers, h => h.Name == "time");
51+
Assert.Contains(handlers, h => h.Name == "wipe");
4852
Assert.Contains(handlers, h => h.Name == "online");
4953
Assert.Contains(handlers, h => h.Name == "offline");
5054
Assert.Contains(handlers, h => h.Name == "team");
5155
Assert.Contains(handlers, h => h.Name == "steamid");
5256
Assert.Contains(handlers, h => h.Name == "alive");
5357
Assert.Contains(handlers, h => h.Name == "prox");
58+
Assert.NotNull(scope.ServiceProvider.GetRequiredService<ServerResolver>());
59+
Assert.NotNull(scope.ServiceProvider.GetRequiredService<ServerQueryService>());
5460
}
5561

5662
[Fact]
@@ -64,6 +70,7 @@ public void Commands_contribute_an_interaction_module_assembly()
6470
services.AddSingleton<IRustServerQuery>(Substitute.For<IRustServerQuery>());
6571
services.AddScoped<IMuteStore>(_ => Substitute.For<IMuteStore>());
6672
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
73+
services.AddScoped<IServerService>(_ => Substitute.For<IServerService>());
6774
services.AddOptions<CommandOptions>();
6875
services.AddCommands();
6976

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using RustPlusBot.Features.Commands.Dispatching;
2+
using RustPlusBot.Features.Commands.Servers;
3+
4+
namespace RustPlusBot.Features.Commands.Tests.Servers;
5+
6+
public sealed class ServerQueryServiceTests
7+
{
8+
[Fact]
9+
public async Task RunsMatchingHandler_AndReturnsReply()
10+
{
11+
var handler = new StubHandler("pop", "Pop: 5/100 (0 queued)");
12+
var service = new ServerQueryService([handler]);
13+
var server = Guid.NewGuid();
14+
15+
var reply = await service.RunAsync("pop", 1UL, server, "en", CancellationToken.None);
16+
17+
Assert.Equal("Pop: 5/100 (0 queued)", reply);
18+
Assert.NotNull(handler.Seen);
19+
Assert.Equal(1UL, handler.Seen!.GuildId);
20+
Assert.Equal(server, handler.Seen.ServerId);
21+
Assert.Equal("en", handler.Seen.Culture);
22+
Assert.Empty(handler.Seen.Args);
23+
Assert.Equal(0UL, handler.Seen.SenderSteamId);
24+
Assert.Equal(string.Empty, handler.Seen.SenderName);
25+
}
26+
27+
[Fact]
28+
public async Task ReturnsNull_WhenNoHandlerMatches()
29+
{
30+
var service = new ServerQueryService([new StubHandler("pop", "x")]);
31+
32+
var reply = await service.RunAsync("time", 1UL, Guid.NewGuid(), "en", CancellationToken.None);
33+
34+
Assert.Null(reply);
35+
}
36+
37+
private sealed class StubHandler(string name, string? reply) : ICommandHandler
38+
{
39+
public CommandContext? Seen { get; private set; }
40+
public string Name => name;
41+
42+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
43+
{
44+
Seen = context;
45+
return Task.FromResult(reply);
46+
}
47+
}
48+
}

0 commit comments

Comments
 (0)