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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<Project Path="src/RustPlusBot.Discord/RustPlusBot.Discord.csproj" />
<Project Path="src/RustPlusBot.Domain/RustPlusBot.Domain.csproj" />
<Project Path="src/RustPlusBot.Host/RustPlusBot.Host.csproj" />
<Project Path="src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj" />
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
Expand All @@ -12,6 +13,7 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
Expand Down
17 changes: 17 additions & 0 deletions src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs
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; }
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
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;

/// <summary>Turns a Discord #teamchat message into an in-game relay (record-then-send), reporting the outcome.</summary>
/// <param name="locator">Resolves whether/which server a channel maps to.</param>
/// <param name="sender">Relays the formatted line into the game.</param>
/// <param name="dedup">Records the relayed line so its in-game echo can be dropped.</param>
/// <param name="scopeFactory">Opens a scope to read the scoped <see cref="IMuteStore"/> mute gate.</param>
internal sealed class TeamChatInboundProcessor(
ITeamChatChannelLocator locator,
ITeamChatSender sender,
RelayDedupBuffer dedup)
RelayDedupBuffer dedup,
IServiceScopeFactory scopeFactory)
{
/// <summary>Processes one observed Discord message.</summary>
/// <param name="message">The reduced message.</param>
Expand All @@ -31,6 +35,18 @@ public async Task<InboundOutcome> 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<IMuteStore>();
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}");

Expand Down
11 changes: 11 additions & 0 deletions src/RustPlusBot.Features.Commands/CommandOptions.cs
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);
}
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 src/RustPlusBot.Features.Commands/Dispatching/CommandContext.cs
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);
53 changes: 53 additions & 0 deletions src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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>
/// <remarks>
/// 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.)
/// </remarks>
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;
}
}
}
}
111 changes: 111 additions & 0 deletions src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs
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 src/RustPlusBot.Features.Commands/Dispatching/CommandLine.cs
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 src/RustPlusBot.Features.Commands/Dispatching/ICommandHandler.cs
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 src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs
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");
}
}
Loading
Loading