-
Notifications
You must be signed in to change notification settings - Fork 0
Subsystem 3b: in-game !command framework (thin slice) #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 15 commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
fabb39d
feat(commands): scaffold Features.Commands project + CommandOptions
HandyS11 e84fb10
feat(commands): add CommandLine parser
HandyS11 75251c6
feat(commands): add per-(server,command) cooldown
HandyS11 1960c8f
feat(persistence): add ServerCommandSettings + IMuteStore + CommandSe…
HandyS11 60e054a
feat(connections): add IRustServerQuery read seam + snapshot mapping
HandyS11 c577aae
feat(commands): add EN/FR command localizer + catalog
HandyS11 762b0e6
feat(commands): add ICommandHandler, CommandContext, BotUptime
HandyS11 416ec1a
feat(commands): add !mute / !unmute handlers
HandyS11 236246d
feat(commands): add !uptime / !pop / !wipe / !time handlers
HandyS11 b87c64d
feat(commands): add CommandDispatcher pipeline
HandyS11 832e83f
feat(commands): add CommandsHostedService consume loop
HandyS11 052b6ab
feat(commands): wire AddCommands DI + host registration
HandyS11 df8c188
feat(chat): gate Discord->game relay on mute state
HandyS11 8e595aa
style: apply ReSharper ReformatAndReorder to 3b files
HandyS11 6ccf104
refactor(commands): drop unused DefaultPrefix option + dead uptime.ok…
HandyS11 0615e22
fix(commands): make CommandCooldown.TryConsume atomic under concurrency
HandyS11 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| namespace RustPlusBot.Domain.Commands; | ||
|
|
||
| /// <summary>Per-(guild, server) command configuration: trigger prefix and mute state.</summary> | ||
| public sealed class ServerCommandSettings | ||
| { | ||
| /// <summary>The owning guild snowflake.</summary> | ||
| public ulong GuildId { get; set; } | ||
|
|
||
| /// <summary>The server id (FK to RustServer; primary key, one row per server).</summary> | ||
| public Guid ServerId { get; set; } | ||
|
|
||
| /// <summary>The command trigger prefix.</summary> | ||
| public string Prefix { get; set; } = "!"; | ||
|
|
||
| /// <summary>Whether all bot-to-game output is currently muted.</summary> | ||
| public bool Muted { get; set; } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| namespace RustPlusBot.Features.Commands; | ||
|
|
||
| /// <summary>Configuration for the in-game command framework.</summary> | ||
| public sealed class CommandOptions | ||
| { | ||
| /// <summary>The configuration section name.</summary> | ||
| public const string SectionName = "Commands"; | ||
|
|
||
| /// <summary>Minimum interval between identical commands on one server.</summary> | ||
| public TimeSpan Cooldown { get; set; } = TimeSpan.FromSeconds(4); | ||
| } |
37 changes: 37 additions & 0 deletions
37
src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using RustPlusBot.Features.Commands.Dispatching; | ||
| using RustPlusBot.Features.Commands.Handlers; | ||
| using RustPlusBot.Features.Commands.Hosting; | ||
| using RustPlusBot.Features.Commands.Localization; | ||
|
|
||
| namespace RustPlusBot.Features.Commands; | ||
|
|
||
| /// <summary>DI registration for the in-game command framework.</summary> | ||
| public static class CommandServiceCollectionExtensions | ||
| { | ||
| /// <summary>Registers the dispatcher, cooldown, localizer, handlers, and hosted service.</summary> | ||
| /// <param name="services">The service collection to add to.</param> | ||
| /// <returns>The same service collection, for chaining.</returns> | ||
| public static IServiceCollection AddCommands(this IServiceCollection services) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(services); | ||
|
|
||
| services.AddSingleton<CommandCooldown>(); | ||
| services.AddSingleton<BotUptime>(); | ||
| // CommandLocalizationCatalog has a required member, so register the prebuilt Default instance. | ||
| services.AddSingleton(CommandLocalizationCatalog.Default); | ||
| services.AddSingleton<ICommandLocalizer, CommandLocalizer>(); | ||
|
|
||
| services.AddScoped<ICommandHandler, MuteCommandHandler>(); | ||
| services.AddScoped<ICommandHandler, UnmuteCommandHandler>(); | ||
| services.AddScoped<ICommandHandler, UptimeCommandHandler>(); | ||
| services.AddScoped<ICommandHandler, PopCommandHandler>(); | ||
| services.AddScoped<ICommandHandler, WipeCommandHandler>(); | ||
| services.AddScoped<ICommandHandler, TimeCommandHandler>(); | ||
|
|
||
| services.AddScoped<CommandDispatcher>(); | ||
| services.AddHostedService<CommandsHostedService>(); | ||
|
|
||
| return services; | ||
| } | ||
| } |
16 changes: 16 additions & 0 deletions
16
src/RustPlusBot.Features.Commands/Dispatching/CommandContext.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| namespace RustPlusBot.Features.Commands.Dispatching; | ||
|
|
||
| /// <summary>Everything a command handler needs for one invocation.</summary> | ||
| /// <param name="GuildId">Owning guild.</param> | ||
| /// <param name="ServerId">Target server.</param> | ||
| /// <param name="Culture">Guild culture (e.g. "en"/"fr") for the reply.</param> | ||
| /// <param name="SenderSteamId">Steam id of the in-game caller.</param> | ||
| /// <param name="SenderName">In-game name of the caller.</param> | ||
| /// <param name="Args">Parsed command arguments.</param> | ||
| internal sealed record CommandContext( | ||
| ulong GuildId, | ||
| Guid ServerId, | ||
| string Culture, | ||
| ulong SenderSteamId, | ||
| string SenderName, | ||
| IReadOnlyList<string> Args); |
38 changes: 38 additions & 0 deletions
38
src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| using System.Collections.Concurrent; | ||
| using Microsoft.Extensions.Options; | ||
| using RustPlusBot.Abstractions.Time; | ||
|
|
||
| namespace RustPlusBot.Features.Commands.Dispatching; | ||
|
|
||
| /// <summary>Per-(server, command) cooldown. Singleton; in-memory; clock-driven.</summary> | ||
| /// <param name="clock">The clock.</param> | ||
| /// <param name="options">The command options carrying the cooldown window.</param> | ||
| internal sealed class CommandCooldown(IClock clock, IOptions<CommandOptions> options) | ||
| { | ||
| private readonly TimeSpan _cooldown = options.Value.Cooldown; | ||
| private readonly ConcurrentDictionary<(Guid Server, string Name), DateTimeOffset> _last = new(); | ||
|
|
||
| /// <summary>Returns true if the command may run now (and records the run); false if on cooldown.</summary> | ||
| /// <param name="serverId">The server id.</param> | ||
| /// <param name="name">The lowercase command name.</param> | ||
| public bool TryConsume(Guid serverId, string name) | ||
| { | ||
| var now = clock.UtcNow; | ||
| var key = (serverId, name); | ||
| var allowed = true; | ||
| _last.AddOrUpdate( | ||
| key, | ||
| now, | ||
| (_, previous) => | ||
| { | ||
| if (now - previous < _cooldown) | ||
| { | ||
| allowed = false; | ||
| return previous; | ||
| } | ||
|
|
||
| return now; | ||
| }); | ||
| return allowed; | ||
| } | ||
| } | ||
111 changes: 111 additions & 0 deletions
111
src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| using Microsoft.Extensions.Logging; | ||
| using RustPlusBot.Abstractions.Events; | ||
| using RustPlusBot.Features.Connections.Listening; | ||
| using RustPlusBot.Persistence.Commands; | ||
| using RustPlusBot.Persistence.Workspace; | ||
|
|
||
| namespace RustPlusBot.Features.Commands.Dispatching; | ||
|
|
||
| /// <summary>Turns a received in-game line into a parsed, authorized, rate-limited command + reply.</summary> | ||
| internal sealed partial class CommandDispatcher | ||
| { | ||
| private static readonly HashSet<string> MuteExempt = new(StringComparer.Ordinal) | ||
| { | ||
| "mute", "unmute" | ||
| }; | ||
|
|
||
| private readonly CommandCooldown _cooldown; | ||
|
|
||
| private readonly Dictionary<string, ICommandHandler> _handlers; | ||
| private readonly ILogger<CommandDispatcher> _logger; | ||
| private readonly IMuteStore _muteStore; | ||
| private readonly ITeamChatSender _sender; | ||
| private readonly IWorkspaceStore _workspace; | ||
|
|
||
| /// <summary>Initializes the dispatcher.</summary> | ||
| /// <param name="handlers">The available command handlers.</param> | ||
| /// <param name="cooldown">The per-(server,command) cooldown.</param> | ||
| /// <param name="muteStore">The per-(guild,server) mute and prefix store.</param> | ||
| /// <param name="workspace">The workspace store (for guild culture).</param> | ||
| /// <param name="sender">Relays the reply into in-game team chat.</param> | ||
| /// <param name="logger">The logger.</param> | ||
| public CommandDispatcher( | ||
| IEnumerable<ICommandHandler> handlers, | ||
| CommandCooldown cooldown, | ||
| IMuteStore muteStore, | ||
| IWorkspaceStore workspace, | ||
| ITeamChatSender sender, | ||
| ILogger<CommandDispatcher> logger) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(handlers); | ||
| _handlers = handlers.ToDictionary(h => h.Name, StringComparer.Ordinal); | ||
| _cooldown = cooldown; | ||
| _muteStore = muteStore; | ||
| _workspace = workspace; | ||
| _sender = sender; | ||
| _logger = logger; | ||
| } | ||
|
|
||
| /// <summary>Dispatches one received team message as a command, if it is one.</summary> | ||
| /// <param name="evt">The received team message.</param> | ||
| /// <param name="cancellationToken">A cancellation token.</param> | ||
| /// <returns>A task that completes when the command has been handled or dropped.</returns> | ||
| public async Task DispatchAsync(TeamMessageReceivedEvent evt, CancellationToken cancellationToken) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(evt); | ||
|
|
||
| if (evt.FromActivePlayer) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var prefix = await _muteStore.GetPrefixAsync(evt.GuildId, evt.ServerId, cancellationToken) | ||
| .ConfigureAwait(false); | ||
| if (!CommandLine.TryParse(prefix, evt.Message, out var line) || | ||
| !_handlers.TryGetValue(line.Name, out var handler)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // Mute is gated before the cooldown so a muted command never consumes a cooldown slot. | ||
| if (!MuteExempt.Contains(line.Name) && | ||
| await _muteStore.GetMutedAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| if (!_cooldown.TryConsume(evt.ServerId, line.Name)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var culture = await _workspace.GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); | ||
| var context = new CommandContext(evt.GuildId, evt.ServerId, culture, evt.SenderSteamId, evt.SenderName, | ||
| line.Args); | ||
|
|
||
| string? reply; | ||
| try | ||
| { | ||
| reply = await handler.ExecuteAsync(context, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| catch (OperationCanceledException) | ||
| { | ||
| throw; | ||
| } | ||
| #pragma warning disable CA1031 // Broad catch: a faulting command must not crash the dispatch loop. | ||
| catch (Exception ex) | ||
| #pragma warning restore CA1031 | ||
| { | ||
| LogHandlerFaulted(_logger, ex, line.Name); | ||
| return; | ||
| } | ||
|
|
||
| if (!string.IsNullOrEmpty(reply)) | ||
| { | ||
| await _sender.SendAsync(evt.GuildId, evt.ServerId, reply, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| } | ||
|
|
||
| [LoggerMessage(Level = LogLevel.Error, Message = "Command handler '{Command}' faulted.")] | ||
| private static partial void LogHandlerFaulted(ILogger logger, Exception exception, string command); | ||
| } |
39 changes: 39 additions & 0 deletions
39
src/RustPlusBot.Features.Commands/Dispatching/CommandLine.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| namespace RustPlusBot.Features.Commands.Dispatching; | ||
|
|
||
| /// <summary>A parsed command line: a lowercase name plus zero or more whitespace-split args.</summary> | ||
| /// <param name="Name">The lowercase command name (without the prefix).</param> | ||
| /// <param name="Args">The whitespace-split arguments.</param> | ||
| public readonly record struct CommandLine(string Name, IReadOnlyList<string> Args) | ||
| { | ||
| /// <summary>Parses <paramref name="rawLine"/> against <paramref name="prefix"/>.</summary> | ||
| /// <param name="prefix">The command prefix (e.g. "!").</param> | ||
| /// <param name="rawLine">The raw in-game line.</param> | ||
| /// <param name="command">The parsed command on success.</param> | ||
| /// <returns>True when the line is a command for this prefix.</returns> | ||
| public static bool TryParse(string prefix, string rawLine, out CommandLine command) | ||
| { | ||
| command = default; | ||
| if (string.IsNullOrWhiteSpace(prefix) || string.IsNullOrWhiteSpace(rawLine)) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var trimmed = rawLine.Trim(); | ||
| if (!trimmed.StartsWith(prefix, StringComparison.Ordinal)) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var body = trimmed[prefix.Length..]; | ||
| var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); | ||
| if (tokens.Length == 0) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var name = tokens[0].ToLowerInvariant(); | ||
| var args = tokens.Length > 1 ? tokens[1..] : []; | ||
| command = new CommandLine(name, args); | ||
| return true; | ||
| } | ||
| } |
14 changes: 14 additions & 0 deletions
14
src/RustPlusBot.Features.Commands/Dispatching/ICommandHandler.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| namespace RustPlusBot.Features.Commands.Dispatching; | ||
|
|
||
| /// <summary>One in-game command. The dispatcher resolves handlers by <see cref="Name"/>.</summary> | ||
| internal interface ICommandHandler | ||
| { | ||
| /// <summary>The lowercase command name (without prefix), e.g. "pop".</summary> | ||
| string Name { get; } | ||
|
|
||
| /// <summary>Executes and returns the localized reply, or null for no reply.</summary> | ||
| /// <param name="context">The command context.</param> | ||
| /// <param name="cancellationToken">A cancellation token.</param> | ||
| /// <returns>The localized reply text, or null for no reply.</returns> | ||
| Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken); | ||
| } |
25 changes: 25 additions & 0 deletions
25
src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| using System.Globalization; | ||
|
|
||
| namespace RustPlusBot.Features.Commands.Formatting; | ||
|
|
||
| /// <summary>Formats durations compactly for in-game replies.</summary> | ||
| internal static class DurationFormat | ||
| { | ||
| /// <summary>Renders a duration as "Xd Yh" / "Yh Zm" / "Zm".</summary> | ||
| /// <param name="span">The duration to render.</param> | ||
| /// <returns>A compact duration string.</returns> | ||
| public static string Compact(TimeSpan span) | ||
| { | ||
| if (span.TotalDays >= 1) | ||
| { | ||
| return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalDays}d {span.Hours}h"); | ||
| } | ||
|
|
||
| if (span.TotalHours >= 1) | ||
| { | ||
| return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalHours}h {span.Minutes}m"); | ||
| } | ||
|
|
||
| return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalMinutes}m"); | ||
| } | ||
| } |
22 changes: 22 additions & 0 deletions
22
src/RustPlusBot.Features.Commands/Handlers/MuteCommandHandler.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| using RustPlusBot.Features.Commands.Dispatching; | ||
| using RustPlusBot.Features.Commands.Localization; | ||
| using RustPlusBot.Persistence.Commands; | ||
|
|
||
| namespace RustPlusBot.Features.Commands.Handlers; | ||
|
|
||
| /// <summary>!mute — silence all bot-to-game output.</summary> | ||
| /// <param name="store">The mute store.</param> | ||
| /// <param name="localizer">The reply localizer.</param> | ||
| internal sealed class MuteCommandHandler(IMuteStore store, ICommandLocalizer localizer) : ICommandHandler | ||
| { | ||
| /// <inheritdoc /> | ||
| public string Name => "mute"; | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(context); | ||
| await store.SetMutedAsync(context.GuildId, context.ServerId, true, cancellationToken).ConfigureAwait(false); | ||
| return localizer.Get("command.mute.done", context.Culture); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch — fixed in 0615e22. Replaced the
AddOrUpdate+ closure-flag pattern with a lock-freeTryGetValue+TryAdd/TryUpdatecompare-and-swap, so exactly one caller wins the write at window-expiry. Added a parallel test (ConcurrentCallers_ForSameKey_LetExactlyOneThrough, 64 threads) that asserts a singletrue.