Skip to content

Commit 240a349

Browse files
HandyS11claude
andauthored
Subsystem 3b: in-game !command framework (thin slice) (#9)
* feat(commands): scaffold Features.Commands project + CommandOptions Create RustPlusBot.Features.Commands (refs Abstractions, Persistence, Connections) and its test project; implement CommandOptions with DefaultPrefix and Cooldown defaults; wire both projects into the solution. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): add CommandLine parser Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): add per-(server,command) cooldown Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(persistence): add ServerCommandSettings + IMuteStore + CommandSettings migration Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(connections): add IRustServerQuery read seam + snapshot mapping Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(commands): add EN/FR command localizer + catalog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): add ICommandHandler, CommandContext, BotUptime Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): add !mute / !unmute handlers Implements Task 8: MuteCommandHandler and UnmuteCommandHandler (internal sealed, primary-constructor), each calling IMuteStore.SetMutedAsync and returning a localized confirmation. Tests written first (TDD); 2 new + 19 prior = 21 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(commands): add !uptime / !pop / !wipe / !time handlers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(commands): add CommandDispatcher pipeline * feat(commands): add CommandsHostedService consume loop * feat(commands): wire AddCommands DI + host registration Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(chat): gate Discord->game relay on mute state Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: apply ReSharper ReformatAndReorder to 3b files * refactor(commands): drop unused DefaultPrefix option + dead uptime.ok.server key Final-review cleanup: DefaultPrefix was bound/validated but never read (the per-server prefix lives in ServerCommandSettings with a "!" fallback in the store), and command.uptime.ok.server was an unused localization key. * fix(commands): make CommandCooldown.TryConsume atomic under concurrency The closure-captured 'allowed' flag inside ConcurrentDictionary.AddOrUpdate's updateFactory was not an atomic gate: the factory can run concurrently/retry, so two threads racing at window-expiry could both observe the same 'previous' and both return true. Replace with a lock-free TryGetValue + TryAdd/TryUpdate CAS so exactly one caller wins. Addresses Copilot review on PR #9. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8132240 commit 240a349

57 files changed

Lines changed: 2486 additions & 11 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<Project Path="src/RustPlusBot.Discord/RustPlusBot.Discord.csproj" />
55
<Project Path="src/RustPlusBot.Domain/RustPlusBot.Domain.csproj" />
66
<Project Path="src/RustPlusBot.Host/RustPlusBot.Host.csproj" />
7+
<Project Path="src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj" />
78
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
89
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
910
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
@@ -12,6 +13,7 @@
1213
</Folder>
1314
<Folder Name="/tests/">
1415
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />
16+
<Project Path="tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj" />
1517
<Project Path="tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj" />
1618
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
1719
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace RustPlusBot.Domain.Commands;
2+
3+
/// <summary>Per-(guild, server) command configuration: trigger prefix and mute state.</summary>
4+
public sealed class ServerCommandSettings
5+
{
6+
/// <summary>The owning guild snowflake.</summary>
7+
public ulong GuildId { get; set; }
8+
9+
/// <summary>The server id (FK to RustServer; primary key, one row per server).</summary>
10+
public Guid ServerId { get; set; }
11+
12+
/// <summary>The command trigger prefix.</summary>
13+
public string Prefix { get; set; } = "!";
14+
15+
/// <summary>Whether all bot-to-game output is currently muted.</summary>
16+
public bool Muted { get; set; }
17+
}

src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
using System.Globalization;
2+
using Microsoft.Extensions.DependencyInjection;
23
using RustPlusBot.Features.Chat.Relaying;
34
using RustPlusBot.Features.Connections.Listening;
45
using RustPlusBot.Features.Workspace.Locating;
6+
using RustPlusBot.Persistence.Commands;
57

68
namespace RustPlusBot.Features.Chat.Inbound;
79

810
/// <summary>Turns a Discord #teamchat message into an in-game relay (record-then-send), reporting the outcome.</summary>
911
/// <param name="locator">Resolves whether/which server a channel maps to.</param>
1012
/// <param name="sender">Relays the formatted line into the game.</param>
1113
/// <param name="dedup">Records the relayed line so its in-game echo can be dropped.</param>
14+
/// <param name="scopeFactory">Opens a scope to read the scoped <see cref="IMuteStore"/> mute gate.</param>
1215
internal sealed class TeamChatInboundProcessor(
1316
ITeamChatChannelLocator locator,
1417
ITeamChatSender sender,
15-
RelayDedupBuffer dedup)
18+
RelayDedupBuffer dedup,
19+
IServiceScopeFactory scopeFactory)
1620
{
1721
/// <summary>Processes one observed Discord message.</summary>
1822
/// <param name="message">The reduced message.</param>
@@ -31,6 +35,18 @@ public async Task<InboundOutcome> ProcessAsync(InboundMessage message, Cancellat
3135
return InboundOutcome.Ignored;
3236
}
3337

38+
// A muted server silences ALL bot->game output: ignore fully (neither record a dedup entry nor send).
39+
// IMuteStore is scoped, so resolve it from a fresh scope rather than capturing it on this singleton.
40+
var scope = scopeFactory.CreateAsyncScope();
41+
await using (scope.ConfigureAwait(false))
42+
{
43+
var muteStore = scope.ServiceProvider.GetRequiredService<IMuteStore>();
44+
if (await muteStore.GetMutedAsync(t.GuildId, t.ServerId, cancellationToken).ConfigureAwait(false))
45+
{
46+
return InboundOutcome.Ignored;
47+
}
48+
}
49+
3450
var key = (t.GuildId, t.ServerId);
3551
var text = string.Create(CultureInfo.InvariantCulture, $"[{message.DisplayName}] {message.Content}");
3652

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace RustPlusBot.Features.Commands;
2+
3+
/// <summary>Configuration for the in-game command framework.</summary>
4+
public sealed class CommandOptions
5+
{
6+
/// <summary>The configuration section name.</summary>
7+
public const string SectionName = "Commands";
8+
9+
/// <summary>Minimum interval between identical commands on one server.</summary>
10+
public TimeSpan Cooldown { get; set; } = TimeSpan.FromSeconds(4);
11+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Features.Commands.Dispatching;
3+
using RustPlusBot.Features.Commands.Handlers;
4+
using RustPlusBot.Features.Commands.Hosting;
5+
using RustPlusBot.Features.Commands.Localization;
6+
7+
namespace RustPlusBot.Features.Commands;
8+
9+
/// <summary>DI registration for the in-game command framework.</summary>
10+
public static class CommandServiceCollectionExtensions
11+
{
12+
/// <summary>Registers the dispatcher, cooldown, localizer, handlers, and hosted service.</summary>
13+
/// <param name="services">The service collection to add to.</param>
14+
/// <returns>The same service collection, for chaining.</returns>
15+
public static IServiceCollection AddCommands(this IServiceCollection services)
16+
{
17+
ArgumentNullException.ThrowIfNull(services);
18+
19+
services.AddSingleton<CommandCooldown>();
20+
services.AddSingleton<BotUptime>();
21+
// CommandLocalizationCatalog has a required member, so register the prebuilt Default instance.
22+
services.AddSingleton(CommandLocalizationCatalog.Default);
23+
services.AddSingleton<ICommandLocalizer, CommandLocalizer>();
24+
25+
services.AddScoped<ICommandHandler, MuteCommandHandler>();
26+
services.AddScoped<ICommandHandler, UnmuteCommandHandler>();
27+
services.AddScoped<ICommandHandler, UptimeCommandHandler>();
28+
services.AddScoped<ICommandHandler, PopCommandHandler>();
29+
services.AddScoped<ICommandHandler, WipeCommandHandler>();
30+
services.AddScoped<ICommandHandler, TimeCommandHandler>();
31+
32+
services.AddScoped<CommandDispatcher>();
33+
services.AddHostedService<CommandsHostedService>();
34+
35+
return services;
36+
}
37+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace RustPlusBot.Features.Commands.Dispatching;
2+
3+
/// <summary>Everything a command handler needs for one invocation.</summary>
4+
/// <param name="GuildId">Owning guild.</param>
5+
/// <param name="ServerId">Target server.</param>
6+
/// <param name="Culture">Guild culture (e.g. "en"/"fr") for the reply.</param>
7+
/// <param name="SenderSteamId">Steam id of the in-game caller.</param>
8+
/// <param name="SenderName">In-game name of the caller.</param>
9+
/// <param name="Args">Parsed command arguments.</param>
10+
internal sealed record CommandContext(
11+
ulong GuildId,
12+
Guid ServerId,
13+
string Culture,
14+
ulong SenderSteamId,
15+
string SenderName,
16+
IReadOnlyList<string> Args);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Collections.Concurrent;
2+
using Microsoft.Extensions.Options;
3+
using RustPlusBot.Abstractions.Time;
4+
5+
namespace RustPlusBot.Features.Commands.Dispatching;
6+
7+
/// <summary>Per-(server, command) cooldown. Singleton; in-memory; clock-driven.</summary>
8+
/// <param name="clock">The clock.</param>
9+
/// <param name="options">The command options carrying the cooldown window.</param>
10+
internal sealed class CommandCooldown(IClock clock, IOptions<CommandOptions> options)
11+
{
12+
private readonly TimeSpan _cooldown = options.Value.Cooldown;
13+
private readonly ConcurrentDictionary<(Guid Server, string Name), DateTimeOffset> _last = new();
14+
15+
/// <summary>Returns true if the command may run now (and records the run); false if on cooldown.</summary>
16+
/// <param name="serverId">The server id.</param>
17+
/// <param name="name">The lowercase command name.</param>
18+
/// <remarks>
19+
/// Uses a lock-free compare-and-swap so concurrent callers for the same key are gated atomically:
20+
/// exactly one thread wins the write and returns true. (The dispatcher is single-threaded per event,
21+
/// but this keeps the gate correct under any caller.)
22+
/// </remarks>
23+
public bool TryConsume(Guid serverId, string name)
24+
{
25+
var now = clock.UtcNow;
26+
var key = (serverId, name);
27+
28+
while (true)
29+
{
30+
if (!_last.TryGetValue(key, out var previous))
31+
{
32+
// No prior run: the thread that inserts first wins; others fall through to the update path.
33+
if (_last.TryAdd(key, now))
34+
{
35+
return true;
36+
}
37+
38+
continue;
39+
}
40+
41+
if (now - previous < _cooldown)
42+
{
43+
return false;
44+
}
45+
46+
// Window elapsed: only the thread whose CAS replaces this exact 'previous' wins.
47+
if (_last.TryUpdate(key, now, previous))
48+
{
49+
return true;
50+
}
51+
}
52+
}
53+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
using Microsoft.Extensions.Logging;
2+
using RustPlusBot.Abstractions.Events;
3+
using RustPlusBot.Features.Connections.Listening;
4+
using RustPlusBot.Persistence.Commands;
5+
using RustPlusBot.Persistence.Workspace;
6+
7+
namespace RustPlusBot.Features.Commands.Dispatching;
8+
9+
/// <summary>Turns a received in-game line into a parsed, authorized, rate-limited command + reply.</summary>
10+
internal sealed partial class CommandDispatcher
11+
{
12+
private static readonly HashSet<string> MuteExempt = new(StringComparer.Ordinal)
13+
{
14+
"mute", "unmute"
15+
};
16+
17+
private readonly CommandCooldown _cooldown;
18+
19+
private readonly Dictionary<string, ICommandHandler> _handlers;
20+
private readonly ILogger<CommandDispatcher> _logger;
21+
private readonly IMuteStore _muteStore;
22+
private readonly ITeamChatSender _sender;
23+
private readonly IWorkspaceStore _workspace;
24+
25+
/// <summary>Initializes the dispatcher.</summary>
26+
/// <param name="handlers">The available command handlers.</param>
27+
/// <param name="cooldown">The per-(server,command) cooldown.</param>
28+
/// <param name="muteStore">The per-(guild,server) mute and prefix store.</param>
29+
/// <param name="workspace">The workspace store (for guild culture).</param>
30+
/// <param name="sender">Relays the reply into in-game team chat.</param>
31+
/// <param name="logger">The logger.</param>
32+
public CommandDispatcher(
33+
IEnumerable<ICommandHandler> handlers,
34+
CommandCooldown cooldown,
35+
IMuteStore muteStore,
36+
IWorkspaceStore workspace,
37+
ITeamChatSender sender,
38+
ILogger<CommandDispatcher> logger)
39+
{
40+
ArgumentNullException.ThrowIfNull(handlers);
41+
_handlers = handlers.ToDictionary(h => h.Name, StringComparer.Ordinal);
42+
_cooldown = cooldown;
43+
_muteStore = muteStore;
44+
_workspace = workspace;
45+
_sender = sender;
46+
_logger = logger;
47+
}
48+
49+
/// <summary>Dispatches one received team message as a command, if it is one.</summary>
50+
/// <param name="evt">The received team message.</param>
51+
/// <param name="cancellationToken">A cancellation token.</param>
52+
/// <returns>A task that completes when the command has been handled or dropped.</returns>
53+
public async Task DispatchAsync(TeamMessageReceivedEvent evt, CancellationToken cancellationToken)
54+
{
55+
ArgumentNullException.ThrowIfNull(evt);
56+
57+
if (evt.FromActivePlayer)
58+
{
59+
return;
60+
}
61+
62+
var prefix = await _muteStore.GetPrefixAsync(evt.GuildId, evt.ServerId, cancellationToken)
63+
.ConfigureAwait(false);
64+
if (!CommandLine.TryParse(prefix, evt.Message, out var line) ||
65+
!_handlers.TryGetValue(line.Name, out var handler))
66+
{
67+
return;
68+
}
69+
70+
// Mute is gated before the cooldown so a muted command never consumes a cooldown slot.
71+
if (!MuteExempt.Contains(line.Name) &&
72+
await _muteStore.GetMutedAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false))
73+
{
74+
return;
75+
}
76+
77+
if (!_cooldown.TryConsume(evt.ServerId, line.Name))
78+
{
79+
return;
80+
}
81+
82+
var culture = await _workspace.GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false);
83+
var context = new CommandContext(evt.GuildId, evt.ServerId, culture, evt.SenderSteamId, evt.SenderName,
84+
line.Args);
85+
86+
string? reply;
87+
try
88+
{
89+
reply = await handler.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
90+
}
91+
catch (OperationCanceledException)
92+
{
93+
throw;
94+
}
95+
#pragma warning disable CA1031 // Broad catch: a faulting command must not crash the dispatch loop.
96+
catch (Exception ex)
97+
#pragma warning restore CA1031
98+
{
99+
LogHandlerFaulted(_logger, ex, line.Name);
100+
return;
101+
}
102+
103+
if (!string.IsNullOrEmpty(reply))
104+
{
105+
await _sender.SendAsync(evt.GuildId, evt.ServerId, reply, cancellationToken).ConfigureAwait(false);
106+
}
107+
}
108+
109+
[LoggerMessage(Level = LogLevel.Error, Message = "Command handler '{Command}' faulted.")]
110+
private static partial void LogHandlerFaulted(ILogger logger, Exception exception, string command);
111+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace RustPlusBot.Features.Commands.Dispatching;
2+
3+
/// <summary>A parsed command line: a lowercase name plus zero or more whitespace-split args.</summary>
4+
/// <param name="Name">The lowercase command name (without the prefix).</param>
5+
/// <param name="Args">The whitespace-split arguments.</param>
6+
public readonly record struct CommandLine(string Name, IReadOnlyList<string> Args)
7+
{
8+
/// <summary>Parses <paramref name="rawLine"/> against <paramref name="prefix"/>.</summary>
9+
/// <param name="prefix">The command prefix (e.g. "!").</param>
10+
/// <param name="rawLine">The raw in-game line.</param>
11+
/// <param name="command">The parsed command on success.</param>
12+
/// <returns>True when the line is a command for this prefix.</returns>
13+
public static bool TryParse(string prefix, string rawLine, out CommandLine command)
14+
{
15+
command = default;
16+
if (string.IsNullOrWhiteSpace(prefix) || string.IsNullOrWhiteSpace(rawLine))
17+
{
18+
return false;
19+
}
20+
21+
var trimmed = rawLine.Trim();
22+
if (!trimmed.StartsWith(prefix, StringComparison.Ordinal))
23+
{
24+
return false;
25+
}
26+
27+
var body = trimmed[prefix.Length..];
28+
var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
29+
if (tokens.Length == 0)
30+
{
31+
return false;
32+
}
33+
34+
var name = tokens[0].ToLowerInvariant();
35+
var args = tokens.Length > 1 ? tokens[1..] : [];
36+
command = new CommandLine(name, args);
37+
return true;
38+
}
39+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace RustPlusBot.Features.Commands.Dispatching;
2+
3+
/// <summary>One in-game command. The dispatcher resolves handlers by <see cref="Name"/>.</summary>
4+
internal interface ICommandHandler
5+
{
6+
/// <summary>The lowercase command name (without prefix), e.g. "pop".</summary>
7+
string Name { get; }
8+
9+
/// <summary>Executes and returns the localized reply, or null for no reply.</summary>
10+
/// <param name="context">The command context.</param>
11+
/// <param name="cancellationToken">A cancellation token.</param>
12+
/// <returns>The localized reply text, or null for no reply.</returns>
13+
Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken);
14+
}

0 commit comments

Comments
 (0)