diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx
index 29b829c5..d539fc99 100644
--- a/RustPlusBot.slnx
+++ b/RustPlusBot.slnx
@@ -4,6 +4,7 @@
+
@@ -12,6 +13,7 @@
+
diff --git a/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs b/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs
new file mode 100644
index 00000000..f58560af
--- /dev/null
+++ b/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs
@@ -0,0 +1,17 @@
+namespace RustPlusBot.Domain.Commands;
+
+/// Per-(guild, server) command configuration: trigger prefix and mute state.
+public sealed class ServerCommandSettings
+{
+ /// The owning guild snowflake.
+ public ulong GuildId { get; set; }
+
+ /// The server id (FK to RustServer; primary key, one row per server).
+ public Guid ServerId { get; set; }
+
+ /// The command trigger prefix.
+ public string Prefix { get; set; } = "!";
+
+ /// Whether all bot-to-game output is currently muted.
+ public bool Muted { get; set; }
+}
diff --git a/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs b/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs
index 63eee0cf..8b1e3852 100644
--- a/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs
+++ b/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs
@@ -1,7 +1,9 @@
using System.Globalization;
+using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Features.Chat.Relaying;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Commands;
namespace RustPlusBot.Features.Chat.Inbound;
@@ -9,10 +11,12 @@ namespace RustPlusBot.Features.Chat.Inbound;
/// Resolves whether/which server a channel maps to.
/// Relays the formatted line into the game.
/// Records the relayed line so its in-game echo can be dropped.
+/// Opens a scope to read the scoped mute gate.
internal sealed class TeamChatInboundProcessor(
ITeamChatChannelLocator locator,
ITeamChatSender sender,
- RelayDedupBuffer dedup)
+ RelayDedupBuffer dedup,
+ IServiceScopeFactory scopeFactory)
{
/// Processes one observed Discord message.
/// The reduced message.
@@ -31,6 +35,18 @@ public async Task ProcessAsync(InboundMessage message, Cancellat
return InboundOutcome.Ignored;
}
+ // A muted server silences ALL bot->game output: ignore fully (neither record a dedup entry nor send).
+ // IMuteStore is scoped, so resolve it from a fresh scope rather than capturing it on this singleton.
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var muteStore = scope.ServiceProvider.GetRequiredService();
+ if (await muteStore.GetMutedAsync(t.GuildId, t.ServerId, cancellationToken).ConfigureAwait(false))
+ {
+ return InboundOutcome.Ignored;
+ }
+ }
+
var key = (t.GuildId, t.ServerId);
var text = string.Create(CultureInfo.InvariantCulture, $"[{message.DisplayName}] {message.Content}");
diff --git a/src/RustPlusBot.Features.Commands/CommandOptions.cs b/src/RustPlusBot.Features.Commands/CommandOptions.cs
new file mode 100644
index 00000000..52458f0a
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/CommandOptions.cs
@@ -0,0 +1,11 @@
+namespace RustPlusBot.Features.Commands;
+
+/// Configuration for the in-game command framework.
+public sealed class CommandOptions
+{
+ /// The configuration section name.
+ public const string SectionName = "Commands";
+
+ /// Minimum interval between identical commands on one server.
+ public TimeSpan Cooldown { get; set; } = TimeSpan.FromSeconds(4);
+}
diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs
new file mode 100644
index 00000000..f55199f6
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs
@@ -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;
+
+/// DI registration for the in-game command framework.
+public static class CommandServiceCollectionExtensions
+{
+ /// Registers the dispatcher, cooldown, localizer, handlers, and hosted service.
+ /// The service collection to add to.
+ /// The same service collection, for chaining.
+ public static IServiceCollection AddCommands(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.AddSingleton();
+ services.AddSingleton();
+ // CommandLocalizationCatalog has a required member, so register the prebuilt Default instance.
+ services.AddSingleton(CommandLocalizationCatalog.Default);
+ services.AddSingleton();
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddScoped();
+ services.AddHostedService();
+
+ return services;
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandContext.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandContext.cs
new file mode 100644
index 00000000..30540b49
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandContext.cs
@@ -0,0 +1,16 @@
+namespace RustPlusBot.Features.Commands.Dispatching;
+
+/// Everything a command handler needs for one invocation.
+/// Owning guild.
+/// Target server.
+/// Guild culture (e.g. "en"/"fr") for the reply.
+/// Steam id of the in-game caller.
+/// In-game name of the caller.
+/// Parsed command arguments.
+internal sealed record CommandContext(
+ ulong GuildId,
+ Guid ServerId,
+ string Culture,
+ ulong SenderSteamId,
+ string SenderName,
+ IReadOnlyList Args);
diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs
new file mode 100644
index 00000000..5eacd821
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs
@@ -0,0 +1,53 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Options;
+using RustPlusBot.Abstractions.Time;
+
+namespace RustPlusBot.Features.Commands.Dispatching;
+
+/// Per-(server, command) cooldown. Singleton; in-memory; clock-driven.
+/// The clock.
+/// The command options carrying the cooldown window.
+internal sealed class CommandCooldown(IClock clock, IOptions options)
+{
+ private readonly TimeSpan _cooldown = options.Value.Cooldown;
+ private readonly ConcurrentDictionary<(Guid Server, string Name), DateTimeOffset> _last = new();
+
+ /// Returns true if the command may run now (and records the run); false if on cooldown.
+ /// The server id.
+ /// The lowercase command name.
+ ///
+ /// Uses a lock-free compare-and-swap so concurrent callers for the same key are gated atomically:
+ /// exactly one thread wins the write and returns true. (The dispatcher is single-threaded per event,
+ /// but this keeps the gate correct under any caller.)
+ ///
+ public bool TryConsume(Guid serverId, string name)
+ {
+ var now = clock.UtcNow;
+ var key = (serverId, name);
+
+ while (true)
+ {
+ if (!_last.TryGetValue(key, out var previous))
+ {
+ // No prior run: the thread that inserts first wins; others fall through to the update path.
+ if (_last.TryAdd(key, now))
+ {
+ return true;
+ }
+
+ continue;
+ }
+
+ if (now - previous < _cooldown)
+ {
+ return false;
+ }
+
+ // Window elapsed: only the thread whose CAS replaces this exact 'previous' wins.
+ if (_last.TryUpdate(key, now, previous))
+ {
+ return true;
+ }
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs
new file mode 100644
index 00000000..61c6d2b4
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs
@@ -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;
+
+/// Turns a received in-game line into a parsed, authorized, rate-limited command + reply.
+internal sealed partial class CommandDispatcher
+{
+ private static readonly HashSet MuteExempt = new(StringComparer.Ordinal)
+ {
+ "mute", "unmute"
+ };
+
+ private readonly CommandCooldown _cooldown;
+
+ private readonly Dictionary _handlers;
+ private readonly ILogger _logger;
+ private readonly IMuteStore _muteStore;
+ private readonly ITeamChatSender _sender;
+ private readonly IWorkspaceStore _workspace;
+
+ /// Initializes the dispatcher.
+ /// The available command handlers.
+ /// The per-(server,command) cooldown.
+ /// The per-(guild,server) mute and prefix store.
+ /// The workspace store (for guild culture).
+ /// Relays the reply into in-game team chat.
+ /// The logger.
+ public CommandDispatcher(
+ IEnumerable handlers,
+ CommandCooldown cooldown,
+ IMuteStore muteStore,
+ IWorkspaceStore workspace,
+ ITeamChatSender sender,
+ ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(handlers);
+ _handlers = handlers.ToDictionary(h => h.Name, StringComparer.Ordinal);
+ _cooldown = cooldown;
+ _muteStore = muteStore;
+ _workspace = workspace;
+ _sender = sender;
+ _logger = logger;
+ }
+
+ /// Dispatches one received team message as a command, if it is one.
+ /// The received team message.
+ /// A cancellation token.
+ /// A task that completes when the command has been handled or dropped.
+ 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);
+}
diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandLine.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandLine.cs
new file mode 100644
index 00000000..7bb1cf69
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandLine.cs
@@ -0,0 +1,39 @@
+namespace RustPlusBot.Features.Commands.Dispatching;
+
+/// A parsed command line: a lowercase name plus zero or more whitespace-split args.
+/// The lowercase command name (without the prefix).
+/// The whitespace-split arguments.
+public readonly record struct CommandLine(string Name, IReadOnlyList Args)
+{
+ /// Parses against .
+ /// The command prefix (e.g. "!").
+ /// The raw in-game line.
+ /// The parsed command on success.
+ /// True when the line is a command for this prefix.
+ 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;
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Dispatching/ICommandHandler.cs b/src/RustPlusBot.Features.Commands/Dispatching/ICommandHandler.cs
new file mode 100644
index 00000000..938ce5d5
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Dispatching/ICommandHandler.cs
@@ -0,0 +1,14 @@
+namespace RustPlusBot.Features.Commands.Dispatching;
+
+/// One in-game command. The dispatcher resolves handlers by .
+internal interface ICommandHandler
+{
+ /// The lowercase command name (without prefix), e.g. "pop".
+ string Name { get; }
+
+ /// Executes and returns the localized reply, or null for no reply.
+ /// The command context.
+ /// A cancellation token.
+ /// The localized reply text, or null for no reply.
+ Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs b/src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs
new file mode 100644
index 00000000..43f3ab2f
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs
@@ -0,0 +1,25 @@
+using System.Globalization;
+
+namespace RustPlusBot.Features.Commands.Formatting;
+
+/// Formats durations compactly for in-game replies.
+internal static class DurationFormat
+{
+ /// Renders a duration as "Xd Yh" / "Yh Zm" / "Zm".
+ /// The duration to render.
+ /// A compact duration string.
+ 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");
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Handlers/MuteCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/MuteCommandHandler.cs
new file mode 100644
index 00000000..cc1d5e3e
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Handlers/MuteCommandHandler.cs
@@ -0,0 +1,22 @@
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Localization;
+using RustPlusBot.Persistence.Commands;
+
+namespace RustPlusBot.Features.Commands.Handlers;
+
+/// !mute — silence all bot-to-game output.
+/// The mute store.
+/// The reply localizer.
+internal sealed class MuteCommandHandler(IMuteStore store, ICommandLocalizer localizer) : ICommandHandler
+{
+ ///
+ public string Name => "mute";
+
+ ///
+ public async Task 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);
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs
new file mode 100644
index 00000000..0353d34c
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs
@@ -0,0 +1,28 @@
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Localization;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Commands.Handlers;
+
+/// !pop — reports current population, slot cap and queue.
+/// The live server query.
+/// The reply localizer.
+internal sealed class PopCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler
+{
+ ///
+ public string Name => "pop";
+
+ ///
+ public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ var info = await query.GetServerInfoAsync(context.GuildId, context.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+ if (info is null)
+ {
+ return localizer.Get("command.notconnected", context.Culture);
+ }
+
+ return localizer.Get("command.pop.ok", context.Culture, info.Players, info.MaxPlayers, info.QueuedPlayers);
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs
new file mode 100644
index 00000000..722e8434
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs
@@ -0,0 +1,35 @@
+using System.Globalization;
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Localization;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Commands.Handlers;
+
+/// !time — reports the in-game clock and whether it is day or night.
+/// The live server query.
+/// The reply localizer.
+internal sealed class TimeCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler
+{
+ ///
+ public string Name => "time";
+
+ ///
+ public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ var time = await query.GetTimeAsync(context.GuildId, context.ServerId, cancellationToken).ConfigureAwait(false);
+ if (time is null)
+ {
+ return localizer.Get("command.notconnected", context.Culture);
+ }
+
+ var isDay = time.TimeOfDay >= time.Sunrise && time.TimeOfDay < time.Sunset;
+ var phase = localizer.Get(isDay ? "command.time.day" : "command.time.night", context.Culture);
+
+ var hours = (int)time.TimeOfDay;
+ var minutes = (int)((time.TimeOfDay - hours) * 60f);
+ var clockText = string.Create(CultureInfo.InvariantCulture, $"{hours:00}:{minutes:00}");
+
+ return localizer.Get("command.time.ok", context.Culture, clockText, phase);
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Handlers/UnmuteCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/UnmuteCommandHandler.cs
new file mode 100644
index 00000000..cd9200bc
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Handlers/UnmuteCommandHandler.cs
@@ -0,0 +1,22 @@
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Localization;
+using RustPlusBot.Persistence.Commands;
+
+namespace RustPlusBot.Features.Commands.Handlers;
+
+/// !unmute — re-enable bot-to-game output.
+/// The mute store.
+/// The reply localizer.
+internal sealed class UnmuteCommandHandler(IMuteStore store, ICommandLocalizer localizer) : ICommandHandler
+{
+ ///
+ public string Name => "unmute";
+
+ ///
+ public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ await store.SetMutedAsync(context.GuildId, context.ServerId, false, cancellationToken).ConfigureAwait(false);
+ return localizer.Get("command.unmute.done", context.Culture);
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs
new file mode 100644
index 00000000..2d338fb1
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs
@@ -0,0 +1,23 @@
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Formatting;
+using RustPlusBot.Features.Commands.Hosting;
+using RustPlusBot.Features.Commands.Localization;
+
+namespace RustPlusBot.Features.Commands.Handlers;
+
+/// !uptime — reports how long the bot process has been running.
+/// The process-uptime baseline.
+/// The reply localizer.
+internal sealed class UptimeCommandHandler(BotUptime uptime, ICommandLocalizer localizer) : ICommandHandler
+{
+ ///
+ public string Name => "uptime";
+
+ ///
+ public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ return Task.FromResult(
+ localizer.Get("command.uptime.ok", context.Culture, DurationFormat.Compact(uptime.Elapsed)));
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs
new file mode 100644
index 00000000..b823260a
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs
@@ -0,0 +1,38 @@
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Formatting;
+using RustPlusBot.Features.Commands.Localization;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Commands.Handlers;
+
+/// !wipe — reports how long ago the server last wiped.
+/// The live server query.
+/// The reply localizer.
+/// The clock used to compute the elapsed time since wipe.
+internal sealed class WipeCommandHandler(IRustServerQuery query, ICommandLocalizer localizer, IClock clock)
+ : ICommandHandler
+{
+ ///
+ public string Name => "wipe";
+
+ ///
+ public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ var info = await query.GetServerInfoAsync(context.GuildId, context.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+ if (info is null)
+ {
+ return localizer.Get("command.notconnected", context.Culture);
+ }
+
+ if (info.WipeTimeUtc is null)
+ {
+ return localizer.Get("command.wipe.unknown", context.Culture);
+ }
+
+ return localizer.Get("command.wipe.ok", context.Culture,
+ DurationFormat.Compact(clock.UtcNow - info.WipeTimeUtc.Value));
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Hosting/BotUptime.cs b/src/RustPlusBot.Features.Commands/Hosting/BotUptime.cs
new file mode 100644
index 00000000..cd651eee
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Hosting/BotUptime.cs
@@ -0,0 +1,13 @@
+using RustPlusBot.Abstractions.Time;
+
+namespace RustPlusBot.Features.Commands.Hosting;
+
+/// Process-uptime baseline, captured when the singleton is first constructed.
+/// The clock.
+internal sealed class BotUptime(IClock clock)
+{
+ private readonly DateTimeOffset _start = clock.UtcNow;
+
+ /// How long the bot has been running.
+ public TimeSpan Elapsed => clock.UtcNow - _start;
+}
diff --git a/src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs b/src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs
new file mode 100644
index 00000000..9f4c4198
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs
@@ -0,0 +1,95 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Commands.Dispatching;
+
+namespace RustPlusBot.Features.Commands.Hosting;
+
+/// Consumes team messages from the bus and dispatches in-game commands.
+/// The in-process event bus.
+/// Creates a DI scope per event (the dispatcher uses scoped stores).
+/// The logger.
+internal sealed partial class CommandsHostedService(
+ IEventBus eventBus,
+ IServiceScopeFactory scopeFactory,
+ ILogger logger) : IHostedService, IDisposable
+{
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _loop;
+
+ ///
+ public void Dispose() => _cts.Dispose();
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _loop = Task.Run(() => ConsumeAsync(_cts.Token), CancellationToken.None);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ await _cts.CancelAsync().ConfigureAwait(false);
+ if (_loop is not null)
+ {
+ try
+ {
+#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — this is our own loop task, joined on stop.
+ await _loop.ConfigureAwait(false);
+#pragma warning restore VSTHRD003
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown.
+ }
+ }
+ }
+
+ private async Task ConsumeAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var evt in eventBus.SubscribeAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ try
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var dispatcher = scope.ServiceProvider.GetRequiredService();
+ await dispatcher.DispatchAsync(evt, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+#pragma warning disable CA1031 // Broad catch: one bad event must not kill the loop.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogDispatchFaulted(logger, ex);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogLoopFaulted(logger, ex);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Command dispatch faulted for one event.")]
+ private static partial void LogDispatchFaulted(ILogger logger, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Command consume loop faulted.")]
+ private static partial void LogLoopFaulted(ILogger logger, Exception exception);
+}
diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs
new file mode 100644
index 00000000..12de9522
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs
@@ -0,0 +1,42 @@
+namespace RustPlusBot.Features.Commands.Localization;
+
+/// The in-memory string catalog for command replies: culture -> (key -> value). English is the fallback.
+internal sealed class CommandLocalizationCatalog
+{
+ /// culture -> key -> value.
+ public required IReadOnlyDictionary> Strings { get; init; }
+
+ /// The built-in EN/FR catalog.
+ public static CommandLocalizationCatalog Default { get; } = new()
+ {
+ Strings = new Dictionary>(StringComparer.Ordinal)
+ {
+ ["en"] = new Dictionary(StringComparer.Ordinal)
+ {
+ ["command.mute.done"] = "Bot muted.",
+ ["command.unmute.done"] = "Bot unmuted.",
+ ["command.uptime.ok"] = "Uptime: {0}",
+ ["command.pop.ok"] = "Pop: {0}/{1} ({2} queued)",
+ ["command.time.ok"] = "Time: {0} — {1}",
+ ["command.time.day"] = "day",
+ ["command.time.night"] = "night",
+ ["command.wipe.ok"] = "Wiped {0} ago",
+ ["command.notconnected"] = "Not connected to the server.",
+ ["command.wipe.unknown"] = "Wipe time is unknown.",
+ },
+ ["fr"] = new Dictionary(StringComparer.Ordinal)
+ {
+ ["command.mute.done"] = "Bot mis en sourdine.",
+ ["command.unmute.done"] = "Bot réactivé.",
+ ["command.uptime.ok"] = "Disponibilité : {0}",
+ ["command.pop.ok"] = "Population : {0}/{1} ({2} en file)",
+ ["command.time.ok"] = "Heure : {0} — {1}",
+ ["command.time.day"] = "jour",
+ ["command.time.night"] = "nuit",
+ ["command.wipe.ok"] = "Wipe il y a {0}",
+ ["command.notconnected"] = "Non connecté au serveur.",
+ ["command.wipe.unknown"] = "Heure de wipe inconnue.",
+ },
+ },
+ };
+}
diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs
new file mode 100644
index 00000000..936a94d9
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs
@@ -0,0 +1,61 @@
+using System.Globalization;
+
+namespace RustPlusBot.Features.Commands.Localization;
+
+/// Dictionary-backed with English fallback and region normalization.
+/// Duplicated from Workspace.Localizer; consolidate into a shared project in a future refactor.
+/// The string catalog.
+internal sealed class CommandLocalizer(CommandLocalizationCatalog catalog) : ICommandLocalizer
+{
+ private const string FallbackCulture = "en";
+
+ ///
+ public string Get(string key, string culture)
+ {
+ var normalized = Normalize(culture);
+ if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
+ {
+ return value;
+ }
+
+ if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) &&
+ fallback.TryGetValue(key, out var fallbackValue))
+ {
+ return fallbackValue;
+ }
+
+ return key;
+ }
+
+ ///
+ public string Get(string key, string culture, params object[] args)
+ {
+ var format = Get(key, culture);
+ var provider = ResolveFormatProvider(Normalize(culture));
+ return string.Format(provider, format, args);
+ }
+
+ private static string Normalize(string culture)
+ {
+ if (string.IsNullOrWhiteSpace(culture))
+ {
+ return FallbackCulture;
+ }
+
+ var dash = culture.IndexOf('-', StringComparison.Ordinal);
+ var primary = dash >= 0 ? culture[..dash] : culture;
+ return primary.ToLowerInvariant();
+ }
+
+ private static CultureInfo ResolveFormatProvider(string culture)
+ {
+ try
+ {
+ return CultureInfo.GetCultureInfo(culture);
+ }
+ catch (CultureNotFoundException)
+ {
+ return CultureInfo.InvariantCulture;
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs b/src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs
new file mode 100644
index 00000000..7c2b8059
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs
@@ -0,0 +1,16 @@
+namespace RustPlusBot.Features.Commands.Localization;
+
+/// Resolves localized in-game reply strings by key and culture, falling back to English.
+internal interface ICommandLocalizer
+{
+ /// Gets the localized string for a key, or the key itself if not found.
+ /// The string key.
+ /// The BCP-47 culture tag (e.g. "en", "fr").
+ string Get(string key, string culture);
+
+ /// Gets the localized, format-applied string.
+ /// The string key.
+ /// The BCP-47 culture tag.
+ /// Format arguments.
+ string Get(string key, string culture, params object[] args);
+}
diff --git a/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj b/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj
new file mode 100644
index 00000000..083fe58d
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
index 186c8cdc..31828268 100644
--- a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
@@ -21,6 +21,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
services.AddScoped();
// Contribute this assembly's interaction modules to the Discord layer.
diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
index f756a1ad..af80be91 100644
--- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
@@ -15,6 +15,18 @@ internal interface IRustServerConnection : IAsyncDisposable
/// The heartbeat result.
Task GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken);
+ /// Gets a server-info snapshot, or null on failure/timeout.
+ /// How long to wait for the response.
+ /// A cancellation token.
+ /// A server-info snapshot, or null on failure/timeout.
+ Task GetServerInfoAsync(TimeSpan timeout, CancellationToken cancellationToken);
+
+ /// Gets an in-game time snapshot, or null on failure/timeout.
+ /// How long to wait for the response.
+ /// A cancellation token.
+ /// An in-game time snapshot, or null on failure/timeout.
+ Task GetTimeAsync(TimeSpan timeout, CancellationToken cancellationToken);
+
/// Sends a message to in-game team chat.
/// The message text to send.
/// A cancellation token.
diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs
new file mode 100644
index 00000000..f93a3752
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs
@@ -0,0 +1,19 @@
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// Reads live data from a connected server's socket (implemented by the connection supervisor).
+public interface IRustServerQuery
+{
+ /// Gets server info, or null when (guildId, serverId) has no live socket.
+ /// The owning guild snowflake.
+ /// The target server id.
+ /// A cancellation token.
+ /// A server-info snapshot, or null when there is no live socket.
+ Task GetServerInfoAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
+
+ /// Gets in-game time, or null when there is no live socket.
+ /// The owning guild snowflake.
+ /// The target server id.
+ /// A cancellation token.
+ /// An in-game time snapshot, or null when there is no live socket.
+ Task GetTimeAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
index 26c84077..d6793b67 100644
--- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
@@ -33,6 +33,12 @@ public Task ConnectAsync(TimeSpan timeout, CancellationTok
public Task GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
Task.FromResult(HeartbeatResult.AuthRejected);
+ public Task GetServerInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
+ Task.FromResult(null);
+
+ public Task GetTimeAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
+ Task.FromResult(null);
+
public Task SendTeamMessageAsync(string message, CancellationToken cancellationToken) =>
Task.CompletedTask;
@@ -140,6 +146,76 @@ public async Task GetInfoAsync(TimeSpan timeout, CancellationTo
}
}
+ public async Task GetServerInfoAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(timeout);
+ try
+ {
+ // CONFIRMED (2.0.0-beta.1): GetInfoAsync returns Task>; Response.IsSuccess/.Data.
+ // ServerInfo getters: PlayerCount/MaxPlayerCount/QueuedPlayerCount are uint?; WipeTime is DateTime?
+ // documented as "UTC time of the last forced wipe" (the AppInfo->ServerInfo mapper yields Kind=Utc).
+ var response = await _rustPlus.GetInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
+ .ConfigureAwait(false);
+ if (!response.IsSuccess || response.Data is null)
+ {
+ return null;
+ }
+
+ var info = response.Data;
+ DateTimeOffset? wipeTime = info.WipeTime is { } wipe
+ ? new DateTimeOffset(DateTime.SpecifyKind(wipe, DateTimeKind.Utc))
+ : null;
+ return new ServerInfoSnapshot(
+ (int)(info.PlayerCount ?? 0u),
+ (int)(info.MaxPlayerCount ?? 0u),
+ (int)(info.QueuedPlayerCount ?? 0u),
+ wipeTime);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return null;
+ }
+#pragma warning disable CA1031 // Broad catch: any info-query failure maps to null; never surface a token/secret.
+ catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
+#pragma warning restore CA1031
+ {
+ LogQueryFailed(_logger, ex);
+ return null;
+ }
+ }
+
+ public async Task GetTimeAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(timeout);
+ try
+ {
+ // CONFIRMED (2.0.0-beta.1): GetTimeAsync returns Task>; Response.IsSuccess/.Data.
+ // TimeInfo getters Time/Sunrise/Sunset are float. 'Time' is the current in-game time of day.
+ var response = await _rustPlus.GetTimeAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
+ .ConfigureAwait(false);
+ if (!response.IsSuccess || response.Data is null)
+ {
+ return null;
+ }
+
+ var time = response.Data;
+ return new ServerTimeSnapshot(time.Time, time.Sunrise, time.Sunset);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return null;
+ }
+#pragma warning disable CA1031 // Broad catch: any time-query failure maps to null; never surface a token/secret.
+ catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
+#pragma warning restore CA1031
+ {
+ LogQueryFailed(_logger, ex);
+ return null;
+ }
+ }
+
public event EventHandler? TeamMessageReceived;
public async Task SendTeamMessageAsync(string message, CancellationToken cancellationToken)
@@ -176,6 +252,9 @@ private void OnTeamChatReceived(object? sender, RustPlusApi.Data.Events.TeamMess
[LoggerMessage(Level = LogLevel.Warning, Message = "Rust+ heartbeat (GetInfo) failed.")]
private static partial void LogHeartbeatFailed(ILogger logger, Exception ex);
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Rust+ server query failed.")]
+ private static partial void LogQueryFailed(ILogger logger, Exception ex);
+
[LoggerMessage(Level = LogLevel.Warning, Message = "Rust+ socket teardown failed.")]
private static partial void LogDisposeFailed(ILogger logger, Exception ex);
}
diff --git a/src/RustPlusBot.Features.Connections/Listening/ServerInfoSnapshot.cs b/src/RustPlusBot.Features.Connections/Listening/ServerInfoSnapshot.cs
new file mode 100644
index 00000000..bb52bdf5
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/ServerInfoSnapshot.cs
@@ -0,0 +1,8 @@
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// A point-in-time view of a server's population and wipe, decoupled from RustPlusApi types.
+/// Current player count.
+/// Server slot cap.
+/// Players waiting in queue.
+/// When the server last wiped, if known.
+public sealed record ServerInfoSnapshot(int Players, int MaxPlayers, int QueuedPlayers, DateTimeOffset? WipeTimeUtc);
diff --git a/src/RustPlusBot.Features.Connections/Listening/ServerTimeSnapshot.cs b/src/RustPlusBot.Features.Connections/Listening/ServerTimeSnapshot.cs
new file mode 100644
index 00000000..56a96b5f
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/ServerTimeSnapshot.cs
@@ -0,0 +1,7 @@
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// A point-in-time view of in-game time, decoupled from RustPlusApi types.
+/// Current in-game time of day (RustPlusApi TimeInfo.Time).
+/// In-game sunrise time (RustPlusApi TimeInfo.Sunrise).
+/// In-game sunset time (RustPlusApi TimeInfo.Sunset).
+public sealed record ServerTimeSnapshot(float TimeOfDay, float Sunrise, float Sunset);
diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
index 9c1b2eeb..1454528e 100644
--- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
+++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
@@ -29,7 +29,7 @@ internal sealed partial class ConnectionSupervisor(
ICredentialProtector protector,
IEventBus eventBus,
IOptions options,
- ILogger logger) : IConnectionSupervisor, ITeamChatSender, IAsyncDisposable
+ ILogger logger) : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAsyncDisposable
{
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Handle> _connections = new();
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -125,6 +125,34 @@ public async Task StopAllAsync()
}
}
+ ///
+ public async Task GetServerInfoAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken)
+ {
+ if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
+ {
+ return null;
+ }
+
+ return await live.Connection.GetServerInfoAsync(_options.HeartbeatTimeout, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ ///
+ public async Task GetTimeAsync(ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken)
+ {
+ if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
+ {
+ return null;
+ }
+
+ return await live.Connection.GetTimeAsync(_options.HeartbeatTimeout, cancellationToken).ConfigureAwait(false);
+ }
+
///
public async Task SendAsync(
ulong guildId,
diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs
index a18dd193..fa2c633d 100644
--- a/src/RustPlusBot.Host/Program.cs
+++ b/src/RustPlusBot.Host/Program.cs
@@ -7,6 +7,7 @@
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Discord;
using RustPlusBot.Features.Chat;
+using RustPlusBot.Features.Commands;
using RustPlusBot.Features.Connections;
using RustPlusBot.Features.Pairing;
using RustPlusBot.Features.Workspace;
@@ -52,6 +53,11 @@
.ValidateOnStart();
builder.Services.AddConnections();
builder.Services.AddChat();
+builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection("Commands"))
+ .Validate(static o => o.Cooldown > TimeSpan.Zero, "Commands:Cooldown must be positive.")
+ .ValidateOnStart();
+builder.Services.AddCommands();
var host = builder.Build();
diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
index 1bbc0120..ac632af6 100644
--- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj
+++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
@@ -22,5 +22,6 @@
+
diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs
index de5a0572..b6775ce4 100644
--- a/src/RustPlusBot.Persistence/BotDbContext.cs
+++ b/src/RustPlusBot.Persistence/BotDbContext.cs
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Persistord.Core;
+using RustPlusBot.Domain.Commands;
using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Credentials;
using RustPlusBot.Domain.Entities;
@@ -30,6 +31,9 @@ public sealed class BotDbContext(DbContextOptions options) : Disco
/// Per-server connection state.
public DbSet ConnectionStates => Set();
+ /// Per-server command settings (trigger prefix and mute state).
+ public DbSet ServerCommandSettings => Set();
+
/// Per-guild settings.
public DbSet GuildSettings => Set();
@@ -59,6 +63,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.ApplyConfiguration(new PlayerCredentialConfiguration())
.ApplyConfiguration(new FcmRegistrationConfiguration())
.ApplyConfiguration(new ConnectionStateConfiguration())
+ .ApplyConfiguration(new ServerCommandSettingsConfiguration())
.ApplyConfiguration(new GuildSettingsConfiguration())
.ApplyConfiguration(new PairedEntityConfiguration())
.ApplyConfiguration(new EventSubscriptionConfiguration())
diff --git a/src/RustPlusBot.Persistence/Commands/IMuteStore.cs b/src/RustPlusBot.Persistence/Commands/IMuteStore.cs
new file mode 100644
index 00000000..54bcf225
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Commands/IMuteStore.cs
@@ -0,0 +1,27 @@
+namespace RustPlusBot.Persistence.Commands;
+
+/// Reads/writes per-(guild, server) command settings: mute state and trigger prefix.
+public interface IMuteStore
+{
+ /// Gets whether bot-to-game output is muted (false when unset).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// A cancellation token.
+ /// True if muted; false when unset.
+ Task GetMutedAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
+
+ /// Sets the mute state, creating the row if needed.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The mute state to persist.
+ /// A cancellation token.
+ /// A task that completes when the state has been persisted.
+ Task SetMutedAsync(ulong guildId, Guid serverId, bool muted, CancellationToken cancellationToken = default);
+
+ /// Gets the command prefix ("!" when unset).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// A cancellation token.
+ /// The trigger prefix, or "!" when unset.
+ Task GetPrefixAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
+}
diff --git a/src/RustPlusBot.Persistence/Commands/MuteStore.cs b/src/RustPlusBot.Persistence/Commands/MuteStore.cs
new file mode 100644
index 00000000..052766e7
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Commands/MuteStore.cs
@@ -0,0 +1,54 @@
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Domain.Commands;
+
+namespace RustPlusBot.Persistence.Commands;
+
+/// EF-backed .
+/// The bot database context.
+public sealed class MuteStore(BotDbContext context) : IMuteStore
+{
+ ///
+ public async Task GetMutedAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default)
+ {
+ var row = await context.ServerCommandSettings
+ .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken)
+ .ConfigureAwait(false);
+ return row?.Muted ?? false;
+ }
+
+ ///
+ public async Task SetMutedAsync(ulong guildId,
+ Guid serverId,
+ bool muted,
+ CancellationToken cancellationToken = default)
+ {
+ var existing = await context.ServerCommandSettings
+ .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (existing is null)
+ {
+ context.ServerCommandSettings.Add(new ServerCommandSettings
+ {
+ GuildId = guildId, ServerId = serverId, Muted = muted,
+ });
+ }
+ else
+ {
+ existing.Muted = muted;
+ }
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task GetPrefixAsync(ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken = default)
+ {
+ var row = await context.ServerCommandSettings
+ .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken)
+ .ConfigureAwait(false);
+ return row?.Prefix ?? "!";
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Configurations/ServerCommandSettingsConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ServerCommandSettingsConfiguration.cs
new file mode 100644
index 00000000..3ce9afdf
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Configurations/ServerCommandSettingsConfiguration.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using RustPlusBot.Domain.Commands;
+using RustPlusBot.Domain.Servers;
+
+namespace RustPlusBot.Persistence.Configurations;
+
+internal sealed class ServerCommandSettingsConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.HasKey(s => s.ServerId);
+ builder.Property(s => s.Prefix).HasMaxLength(8);
+
+ // Removing a RustServer cascades to its single command-settings row so no orphaned config lingers.
+ builder.HasOne()
+ .WithOne()
+ .HasForeignKey(s => s.ServerId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.Designer.cs
new file mode 100644
index 00000000..558f090f
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.Designer.cs
@@ -0,0 +1,504 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using RustPlusBot.Persistence;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ [DbContext(typeof(BotDbContext))]
+ [Migration("20260616175850_CommandSettings")]
+ partial class CommandSettings
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
+
+ modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParentId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("ParentId");
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Guilds");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("JoinedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Nickname")
+ .HasColumnType("TEXT");
+
+ b.HasKey("GuildId", "UserId");
+
+ b.ToTable("Members");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("Color")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Permissions")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.ToTable("Roles");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalName")
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Muted")
+ .HasColumnType("INTEGER");
+
+ b.Property("Prefix")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("TEXT");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerCommandSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ActiveCredentialId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayerCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("RustServerId");
+
+ b.ToTable("ConnectionStates");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedFcmCredentials")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("FcmRegistrations");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedPlayerToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("SteamId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("PlayerCredentials");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "RustServerId");
+
+ b.ToTable("PairedEntities");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EventKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "RustServerId");
+
+ b.ToTable("EventSubscriptions");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Culture")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.HasKey("GuildId");
+
+ b.ToTable("GuildSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Ip")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Port")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("GuildId", "Ip", "Port")
+ .IsUnique();
+
+ b.ToTable("RustServers");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordCategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId")
+ .IsUnique();
+
+ b.ToTable("ProvisionedCategories");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "ChannelKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedChannels");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("DiscordMessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "MessageKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedMessages");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b =>
+ {
+ b.HasOne("Persistord.Core.Entities.ChannelEntity", null)
+ .WithMany()
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.cs b/src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.cs
new file mode 100644
index 00000000..7352faab
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.cs
@@ -0,0 +1,42 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ ///
+ public partial class CommandSettings : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "ServerCommandSettings",
+ columns: table => new
+ {
+ ServerId = table.Column(type: "TEXT", nullable: false),
+ GuildId = table.Column(type: "INTEGER", nullable: false),
+ Prefix = table.Column(type: "TEXT", maxLength: 8, nullable: false),
+ Muted = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ServerCommandSettings", x => x.ServerId);
+ table.ForeignKey(
+ name: "FK_ServerCommandSettings_RustServers_ServerId",
+ column: x => x.ServerId,
+ principalTable: "RustServers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ServerCommandSettings");
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
index dc340355..f864f3ec 100644
--- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
+++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
@@ -122,6 +122,27 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("Users");
});
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Muted")
+ .HasColumnType("INTEGER");
+
+ b.Property("Prefix")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("TEXT");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerCommandSettings");
+ });
+
modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
{
b.Property("RustServerId")
@@ -424,6 +445,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.OnDelete(DeleteBehavior.Restrict);
});
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
{
b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
index 7b944672..d3972517 100644
--- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Persistence.Commands;
using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Credentials;
using RustPlusBot.Persistence.Servers;
@@ -33,6 +34,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs
index f6ef6036..596b0493 100644
--- a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs
@@ -10,6 +10,7 @@
using RustPlusBot.Features.Chat.Webhooks;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Commands;
namespace RustPlusBot.Features.Chat.Tests;
@@ -70,6 +71,10 @@ private static ServiceProvider BuildProvider(
services.AddSingleton(locator);
services.AddSingleton(sender);
services.AddLogging();
+
+ // IMuteStore is scoped in production; register it scoped here so ValidateScopes catches any captive
+ // dependency on the singleton inbound processor (it must resolve the store per message, not capture it).
+ services.AddScoped(_ => Substitute.For());
services.AddChat();
// Override the real webhook poster so the relay's (non-)posting can be asserted.
diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs
index 4751f024..13df732b 100644
--- a/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs
+++ b/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs
@@ -1,9 +1,11 @@
+using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Features.Chat.Inbound;
using RustPlusBot.Features.Chat.Relaying;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Commands;
namespace RustPlusBot.Features.Chat.Tests;
@@ -11,8 +13,8 @@ public sealed class TeamChatInboundProcessorTests
{
private static readonly Guid ServerId = Guid.NewGuid();
- private static (TeamChatInboundProcessor Processor, ITeamChatSender Sender, RelayDedupBuffer Dedup) Build(
- TeamChatSendResult sendResult = TeamChatSendResult.Sent)
+ private static (TeamChatInboundProcessor Processor, ITeamChatSender Sender, RelayDedupBuffer Dedup, IMuteStore Mute)
+ Build(TeamChatSendResult sendResult = TeamChatSendResult.Sent)
{
var clock = Substitute.For();
clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
@@ -24,14 +26,22 @@ private static (TeamChatInboundProcessor Processor, ITeamChatSender Sender, Rela
locator.ResolveAsync(777UL, Arg.Any()).Returns(((ulong, Guid)?)(10UL, ServerId));
locator.ResolveAsync(Arg.Is(c => c != 777UL), Arg.Any())
.Returns(((ulong, Guid)?)null);
- var processor = new TeamChatInboundProcessor(locator, sender, dedup);
- return (processor, sender, dedup);
+ var muteStore = Substitute.For();
+ muteStore.GetMutedAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(false);
+ var scopeFactory = Substitute.For();
+ var scope = Substitute.For();
+ var scopeProvider = Substitute.For();
+ scopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore);
+ scope.ServiceProvider.Returns(scopeProvider);
+ scopeFactory.CreateScope().Returns(scope);
+ var processor = new TeamChatInboundProcessor(locator, sender, dedup, scopeFactory);
+ return (processor, sender, dedup, muteStore);
}
[Fact]
public async Task Ignores_bot_or_webhook_authors()
{
- var (processor, sender, _) = Build();
+ var (processor, sender, _, _) = Build();
var msg = new InboundMessage(AuthorIsBotOrWebhook: true, 777UL, "Alice", "hi");
var outcome = await processor.ProcessAsync(msg, CancellationToken.None);
@@ -44,7 +54,7 @@ await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.An
[Fact]
public async Task Ignores_non_teamchat_channels()
{
- var (processor, sender, _) = Build();
+ var (processor, sender, _, _) = Build();
var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 555UL, "Alice", "hi");
var outcome = await processor.ProcessAsync(msg, CancellationToken.None);
@@ -57,7 +67,7 @@ await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.An
[Fact]
public async Task Ignores_empty_content()
{
- var (processor, sender, _) = Build();
+ var (processor, sender, _, _) = Build();
var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", " ");
var outcome = await processor.ProcessAsync(msg, CancellationToken.None);
@@ -70,7 +80,7 @@ await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.An
[Fact]
public async Task Formats_records_and_sends()
{
- var (processor, sender, dedup) = Build();
+ var (processor, sender, dedup, _) = Build();
var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", "hello");
var outcome = await processor.ProcessAsync(msg, CancellationToken.None);
@@ -83,11 +93,25 @@ public async Task Formats_records_and_sends()
[Fact]
public async Task Reports_failed_when_not_connected()
{
- var (processor, _, _) = Build(TeamChatSendResult.NotConnected);
+ var (processor, _, _, _) = Build(TeamChatSendResult.NotConnected);
var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", "hello");
var outcome = await processor.ProcessAsync(msg, CancellationToken.None);
Assert.Equal(InboundOutcome.Failed, outcome);
}
+
+ [Fact]
+ public async Task Ignores_muted_server()
+ {
+ var (processor, sender, _, mute) = Build();
+ mute.GetMutedAsync(10UL, ServerId, Arg.Any()).Returns(true);
+ var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", "hello");
+
+ var outcome = await processor.ProcessAsync(msg, CancellationToken.None);
+
+ Assert.Equal(InboundOutcome.Ignored, outcome);
+ await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any());
+ }
}
diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs
new file mode 100644
index 00000000..2e010caf
--- /dev/null
+++ b/tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs
@@ -0,0 +1,13 @@
+using RustPlusBot.Features.Commands;
+
+namespace RustPlusBot.Features.Commands.Tests;
+
+public sealed class CommandOptionsTests
+{
+ [Fact]
+ public void Defaults_AreSensible()
+ {
+ var options = new CommandOptions();
+ Assert.Equal(TimeSpan.FromSeconds(4), options.Cooldown);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs
new file mode 100644
index 00000000..3a37ef7f
--- /dev/null
+++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs
@@ -0,0 +1,48 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using NSubstitute;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Hosting;
+using RustPlusBot.Features.Commands.Localization;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Persistence.Commands;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Commands.Tests;
+
+public sealed class CommandRegistrationTests
+{
+ [Fact]
+ public void Dispatcher_and_handlers_resolve()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(Substitute.For());
+ services.AddSingleton(Substitute.For());
+ services.AddSingleton(Substitute.For());
+ services.AddSingleton(Substitute.For());
+ services.AddScoped(_ => Substitute.For());
+ services.AddScoped(_ => Substitute.For());
+ services.AddOptions();
+ services.AddCommands();
+
+ using var provider = services.BuildServiceProvider(validateScopes: true);
+
+ Assert.Contains(provider.GetServices(), h => h is CommandsHostedService);
+
+ // The singletons AddCommands wires must each resolve (guards against an accidentally dropped registration).
+ Assert.NotNull(provider.GetRequiredService());
+ Assert.NotNull(provider.GetRequiredService());
+ Assert.NotNull(provider.GetRequiredService());
+
+ using var scope = provider.CreateScope();
+ Assert.NotNull(scope.ServiceProvider.GetRequiredService());
+ var handlers = scope.ServiceProvider.GetServices().ToList();
+ Assert.Equal(6, handlers.Count);
+ Assert.Contains(handlers, h => h.Name == "mute");
+ Assert.Contains(handlers, h => h.Name == "pop");
+ Assert.Contains(handlers, h => h.Name == "time");
+ }
+}
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs
new file mode 100644
index 00000000..543b6467
--- /dev/null
+++ b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs
@@ -0,0 +1,77 @@
+using Microsoft.Extensions.Options;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Features.Commands;
+using RustPlusBot.Features.Commands.Dispatching;
+
+namespace RustPlusBot.Features.Commands.Tests.Dispatching;
+
+public sealed class CommandCooldownTests
+{
+ private static CommandCooldown Make(TestClock clock) =>
+ new(clock, Options.Create(new CommandOptions
+ {
+ Cooldown = TimeSpan.FromSeconds(4)
+ }));
+
+ [Fact]
+ public void FirstUse_IsAllowed()
+ {
+ Assert.True(Make(new TestClock()).TryConsume(Guid.NewGuid(), "pop"));
+ }
+
+ [Fact]
+ public void WithinWindow_IsBlocked()
+ {
+ var clock = new TestClock();
+ var cd = Make(clock);
+ var server = Guid.NewGuid();
+ Assert.True(cd.TryConsume(server, "pop"));
+ clock.UtcNow = clock.UtcNow.AddSeconds(3);
+ Assert.False(cd.TryConsume(server, "pop"));
+ }
+
+ [Fact]
+ public void AfterWindow_IsAllowed()
+ {
+ var clock = new TestClock();
+ var cd = Make(clock);
+ var server = Guid.NewGuid();
+ Assert.True(cd.TryConsume(server, "pop"));
+ clock.UtcNow = clock.UtcNow.AddSeconds(5);
+ Assert.True(cd.TryConsume(server, "pop"));
+ }
+
+ [Fact]
+ public void DifferentCommand_OrServer_IsIndependent()
+ {
+ var clock = new TestClock();
+ var cd = Make(clock);
+ var server = Guid.NewGuid();
+ Assert.True(cd.TryConsume(server, "pop"));
+ Assert.True(cd.TryConsume(server, "time"));
+ Assert.True(cd.TryConsume(Guid.NewGuid(), "pop"));
+ }
+
+ [Fact]
+ public void ConcurrentCallers_ForSameKey_LetExactlyOneThrough()
+ {
+ var cd = Make(new TestClock());
+ var server = Guid.NewGuid();
+ var allowedCount = 0;
+
+ Parallel.For(0, 64, _ =>
+ {
+ if (cd.TryConsume(server, "pop"))
+ {
+ Interlocked.Increment(ref allowedCount);
+ }
+ });
+
+ Assert.Equal(1, allowedCount);
+ }
+
+ private sealed class TestClock : IClock
+ {
+ public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch;
+ }
+}
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs
new file mode 100644
index 00000000..7c1b4974
--- /dev/null
+++ b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs
@@ -0,0 +1,127 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Features.Commands;
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Persistence.Commands;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Commands.Tests.Dispatching;
+
+public sealed class CommandDispatcherTests
+{
+ private static (CommandDispatcher Sut, ITeamChatSender Sender, StubHandler Handler) Build(
+ bool muted = false,
+ string prefix = "!",
+ string handlerName = "pop")
+ {
+ var sender = Substitute.For();
+ var settings = Substitute.For();
+ settings.GetMutedAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(muted);
+ settings.GetPrefixAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(prefix);
+ var workspace = Substitute.For();
+ workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en");
+ var handler = new StubHandler(handlerName);
+ var cooldown = new CommandCooldown(new TestClock(),
+ Microsoft.Extensions.Options.Options.Create(new CommandOptions()));
+ var sut = new CommandDispatcher([handler], cooldown, settings, workspace, sender,
+ NullLogger.Instance);
+ return (sut, sender, handler);
+ }
+
+ private static TeamMessageReceivedEvent Evt(string msg, bool fromActive = false) =>
+ new(1, Guid.NewGuid(), 7, "alice", msg, fromActive);
+
+ [Fact]
+ public async Task RunsHandler_AndSendsReply()
+ {
+ var (sut, sender, handler) = Build();
+ await sut.DispatchAsync(Evt("!pop"), CancellationToken.None);
+ Assert.Equal(1, handler.Calls);
+ await sender.Received(1).SendAsync(1, Arg.Any(), "reply", Arg.Any());
+ }
+
+ [Fact]
+ public async Task Ignores_FromActivePlayer()
+ {
+ var (sut, sender, handler) = Build();
+ await sut.DispatchAsync(Evt("!pop", fromActive: true), CancellationToken.None);
+ Assert.Equal(0, handler.Calls);
+ await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Ignores_NonCommandLine()
+ {
+ var (sut, _, handler) = Build();
+ await sut.DispatchAsync(Evt("hello team"), CancellationToken.None);
+ Assert.Equal(0, handler.Calls);
+ }
+
+ [Fact]
+ public async Task Ignores_UnknownCommand()
+ {
+ var (sut, sender, handler) = Build();
+ await sut.DispatchAsync(Evt("!nope"), CancellationToken.None);
+ Assert.Equal(0, handler.Calls);
+ await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Drops_WhenMuted_AndNotMuteCommand()
+ {
+ var (sut, sender, handler) = Build(muted: true); // handler is "pop"
+ await sut.DispatchAsync(Evt("!pop"), CancellationToken.None);
+ Assert.Equal(0, handler.Calls);
+ await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Runs_MuteCommand_EvenWhenMuted()
+ {
+ var (sut, sender, handler) = Build(muted: true, handlerName: "mute");
+ await sut.DispatchAsync(Evt("!mute"), CancellationToken.None);
+ Assert.Equal(1, handler.Calls);
+ await sender.Received(1).SendAsync(1, Arg.Any(), "reply", Arg.Any());
+ }
+
+ [Fact]
+ public async Task Honors_CustomPrefix()
+ {
+ var (sut, _, handler) = Build(prefix: ".");
+ await sut.DispatchAsync(Evt(".pop"), CancellationToken.None);
+ Assert.Equal(1, handler.Calls);
+ }
+
+ [Fact]
+ public async Task Cooldown_DropsSecondCall()
+ {
+ var (sut, _, handler) = Build();
+ var evt = Evt("!pop");
+ await sut.DispatchAsync(evt, CancellationToken.None);
+ await sut.DispatchAsync(evt, CancellationToken.None); // same server+command within window
+ Assert.Equal(1, handler.Calls);
+ }
+
+ private sealed class TestClock : IClock
+ {
+ public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch;
+ }
+
+ private sealed class StubHandler(string name) : ICommandHandler
+ {
+ public int Calls { get; private set; }
+ public string Name => name;
+
+ public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
+ {
+ Calls++;
+ return Task.FromResult("reply");
+ }
+ }
+}
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandLineTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandLineTests.cs
new file mode 100644
index 00000000..8c8b6e9e
--- /dev/null
+++ b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandLineTests.cs
@@ -0,0 +1,52 @@
+using RustPlusBot.Features.Commands.Dispatching;
+
+namespace RustPlusBot.Features.Commands.Tests.Dispatching;
+
+public sealed class CommandLineTests
+{
+ private static readonly string[] NowHere = ["now", "here"];
+ private static readonly string[] AB = ["a", "b"];
+
+ [Fact]
+ public void Parses_NameAndArgs()
+ {
+ var parsed = CommandLine.TryParse("!", "!pop now here", out var result);
+ Assert.True(parsed);
+ Assert.Equal("pop", result.Name);
+ Assert.Equal(NowHere, result.Args);
+ }
+
+ [Fact]
+ public void NameIsLowercased()
+ {
+ Assert.True(CommandLine.TryParse("!", "!POP", out var result));
+ Assert.Equal("pop", result.Name);
+ }
+
+ [Theory]
+ [InlineData("pop")]
+ [InlineData("!")]
+ [InlineData("! ")]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Rejects_NonCommands(string line)
+ {
+ Assert.False(CommandLine.TryParse("!", line, out _));
+ }
+
+ [Fact]
+ public void TrimsAndCollapsesWhitespace()
+ {
+ Assert.True(CommandLine.TryParse("!", " !time a b ", out var result));
+ Assert.Equal("time", result.Name);
+ Assert.Equal(AB, result.Args);
+ }
+
+ [Fact]
+ public void Honors_CustomPrefix()
+ {
+ Assert.True(CommandLine.TryParse(".", ".pop", out var result));
+ Assert.Equal("pop", result.Name);
+ Assert.False(CommandLine.TryParse(".", "!pop", out _));
+ }
+}
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Formatting/DurationFormatTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Formatting/DurationFormatTests.cs
new file mode 100644
index 00000000..9710fec8
--- /dev/null
+++ b/tests/RustPlusBot.Features.Commands.Tests/Formatting/DurationFormatTests.cs
@@ -0,0 +1,13 @@
+using RustPlusBot.Features.Commands.Formatting;
+
+namespace RustPlusBot.Features.Commands.Tests.Formatting;
+
+public sealed class DurationFormatTests
+{
+ [Theory]
+ [InlineData(0, 0, 30, "30m")]
+ [InlineData(0, 5, 0, "5h 0m")]
+ [InlineData(2, 3, 0, "2d 3h")]
+ public void Compact(int days, int hours, int minutes, string expected) =>
+ Assert.Equal(expected, DurationFormat.Compact(new TimeSpan(days, hours, minutes, 0)));
+}
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/MuteHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/MuteHandlersTests.cs
new file mode 100644
index 00000000..b095db36
--- /dev/null
+++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/MuteHandlersTests.cs
@@ -0,0 +1,39 @@
+using NSubstitute;
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Handlers;
+using RustPlusBot.Features.Commands.Localization;
+using RustPlusBot.Persistence.Commands;
+
+namespace RustPlusBot.Features.Commands.Tests.Handlers;
+
+public sealed class MuteHandlersTests
+{
+ private static readonly ICommandLocalizer Loc = new CommandLocalizer(CommandLocalizationCatalog.Default);
+
+ private static CommandContext Ctx(string culture = "en") =>
+ new(1, Guid.NewGuid(), culture, 7, "alice", []);
+
+ [Fact]
+ public async Task Mute_SetsMutedTrue_AndConfirms()
+ {
+ var store = Substitute.For();
+ var handler = new MuteCommandHandler(store, Loc);
+ var ctx = Ctx();
+ var reply = await handler.ExecuteAsync(ctx, CancellationToken.None);
+ await store.Received(1).SetMutedAsync(ctx.GuildId, ctx.ServerId, true, Arg.Any());
+ Assert.Equal("Bot muted.", reply);
+ Assert.Equal("mute", handler.Name);
+ }
+
+ [Fact]
+ public async Task Unmute_SetsMutedFalse_AndConfirms_InFrench()
+ {
+ var store = Substitute.For();
+ var handler = new UnmuteCommandHandler(store, Loc);
+ var ctx = Ctx("fr");
+ var reply = await handler.ExecuteAsync(ctx, CancellationToken.None);
+ await store.Received(1).SetMutedAsync(ctx.GuildId, ctx.ServerId, false, Arg.Any());
+ Assert.Equal("Bot réactivé.", reply);
+ Assert.Equal("unmute", handler.Name);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs
new file mode 100644
index 00000000..0f362a0f
--- /dev/null
+++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs
@@ -0,0 +1,120 @@
+using NSubstitute;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Handlers;
+using RustPlusBot.Features.Commands.Hosting;
+using RustPlusBot.Features.Commands.Localization;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Commands.Tests.Handlers;
+
+public sealed class QueryHandlersTests
+{
+ private static readonly ICommandLocalizer Loc = new CommandLocalizer(CommandLocalizationCatalog.Default);
+ private static CommandContext Ctx() => new(1, Guid.NewGuid(), "en", 7, "alice", []);
+
+ [Fact]
+ public async Task Pop_FormatsPlayersMaxQueued()
+ {
+ var query = Substitute.For();
+ var ctx = Ctx();
+ query.GetServerInfoAsync(ctx.GuildId, ctx.ServerId, Arg.Any