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()) + .Returns(new ServerInfoSnapshot(5, 100, 2, null)); + var reply = await new PopCommandHandler(query, Loc).ExecuteAsync(ctx, CancellationToken.None); + Assert.Equal("Pop: 5/100 (2 queued)", reply); + } + + [Fact] + public async Task Pop_NotConnected_WhenNull() + { + var query = Substitute.For(); + query.GetServerInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ServerInfoSnapshot?)null); + var reply = await new PopCommandHandler(query, Loc).ExecuteAsync(Ctx(), CancellationToken.None); + Assert.Equal("Not connected to the server.", reply); + } + + [Fact] + public async Task Wipe_ReportsAgo() + { + var clock = new TestClock + { + UtcNow = DateTimeOffset.UnixEpoch.AddDays(2) + }; + var query = Substitute.For(); + var ctx = Ctx(); + query.GetServerInfoAsync(ctx.GuildId, ctx.ServerId, Arg.Any()) + .Returns(new ServerInfoSnapshot(1, 100, 0, DateTimeOffset.UnixEpoch)); + var reply = await new WipeCommandHandler(query, Loc, clock).ExecuteAsync(ctx, CancellationToken.None); + Assert.Equal("Wiped 2d 0h ago", reply); + } + + [Fact] + public async Task Wipe_Unknown_WhenWipeTimeNull() + { + var query = Substitute.For(); + var ctx = Ctx(); + query.GetServerInfoAsync(ctx.GuildId, ctx.ServerId, Arg.Any()) + .Returns(new ServerInfoSnapshot(1, 100, 0, null)); + var reply = await new WipeCommandHandler(query, Loc, new TestClock()).ExecuteAsync(ctx, CancellationToken.None); + Assert.Equal("Wipe time is unknown.", reply); + } + + [Fact] + public async Task Time_ReportsDay_AtNoon() + { + var query = Substitute.For(); + var ctx = Ctx(); + query.GetTimeAsync(ctx.GuildId, ctx.ServerId, Arg.Any()) + .Returns(new ServerTimeSnapshot(12f, 7f, 20f)); // noon, day from 7..20 + var reply = await new TimeCommandHandler(query, Loc).ExecuteAsync(ctx, CancellationToken.None); + Assert.Contains("day", reply, StringComparison.Ordinal); + } + + [Fact] + public async Task Time_FormatsClock_FromFractionalHour() + { + var query = Substitute.For(); + var ctx = Ctx(); + query.GetTimeAsync(ctx.GuildId, ctx.ServerId, Arg.Any()) + .Returns(new ServerTimeSnapshot(13.5f, 7f, 20f)); // 13:30, day + var reply = await new TimeCommandHandler(query, Loc).ExecuteAsync(ctx, CancellationToken.None); + Assert.Equal("Time: 13:30 — day", reply); + } + + [Fact] + public async Task Time_ReportsNight_AtMidnight() + { + var query = Substitute.For(); + var ctx = Ctx(); + query.GetTimeAsync(ctx.GuildId, ctx.ServerId, Arg.Any()) + .Returns(new ServerTimeSnapshot(2f, 7f, 20f)); // 02:00, before sunrise -> night + var reply = await new TimeCommandHandler(query, Loc).ExecuteAsync(ctx, CancellationToken.None); + Assert.Contains("night", reply, StringComparison.Ordinal); + } + + [Fact] + public async Task Time_NotConnected_WhenNull() + { + var query = Substitute.For(); + query.GetTimeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ServerTimeSnapshot?)null); + var reply = await new TimeCommandHandler(query, Loc).ExecuteAsync(Ctx(), CancellationToken.None); + Assert.Equal("Not connected to the server.", reply); + } + + [Fact] + public async Task Uptime_ReportsBotUptime() + { + var clock = new TestClock(); + var uptime = new BotUptime(clock); + clock.UtcNow = clock.UtcNow.AddHours(3); + var reply = await new UptimeCommandHandler(uptime, Loc).ExecuteAsync(Ctx(), CancellationToken.None); + Assert.Equal("Uptime: 3h 0m", reply); + } + + private sealed class TestClock : IClock + { + public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch; + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/Hosting/BotUptimeTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Hosting/BotUptimeTests.cs new file mode 100644 index 00000000..428196d7 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Hosting/BotUptimeTests.cs @@ -0,0 +1,21 @@ +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Commands.Hosting; + +namespace RustPlusBot.Features.Commands.Tests.Hosting; + +public sealed class BotUptimeTests +{ + [Fact] + public void Elapsed_IsDifferenceSinceConstruction() + { + var clock = new TestClock(); + var uptime = new BotUptime(clock); // captures start at construction + clock.UtcNow = clock.UtcNow.AddMinutes(90); + Assert.Equal(TimeSpan.FromMinutes(90), uptime.Elapsed); + } + + private sealed class TestClock : IClock + { + public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch; + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/Localization/CommandLocalizerTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Localization/CommandLocalizerTests.cs new file mode 100644 index 00000000..75d542ca --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Localization/CommandLocalizerTests.cs @@ -0,0 +1,24 @@ +using RustPlusBot.Features.Commands.Localization; + +namespace RustPlusBot.Features.Commands.Tests.Localization; + +public sealed class CommandLocalizerTests +{ + private static readonly CommandLocalizer Sut = new(CommandLocalizationCatalog.Default); + + [Fact] + public void ReturnsFrench_WhenCultureFr() => + Assert.Equal("Bot mis en sourdine.", Sut.Get("command.mute.done", "fr")); + + [Fact] + public void FallsBackToEnglish_WhenCultureUnknown() => + Assert.Equal("Bot muted.", Sut.Get("command.mute.done", "xx")); + + [Fact] + public void FormatsArgs() => + Assert.Equal("Pop: 5/100 (2 queued)", Sut.Get("command.pop.ok", "en", 5, 100, 2)); + + [Fact] + public void NormalizesRegion() => + Assert.Equal("Bot mis en sourdine.", Sut.Get("command.mute.done", "fr-FR")); +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj b/tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj new file mode 100644 index 00000000..af489ab4 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 15c37196..8d2a793d 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -66,6 +66,12 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// Gets the messages sent via . public List SentMessages { get; } = []; + /// The snapshot returned by . Defaults to a non-null zero snapshot. + public ServerInfoSnapshot? InfoResult { get; set; } = new(0, 0, 0, null); + + /// The snapshot returned by . Defaults to a non-null zero snapshot. + public ServerTimeSnapshot? TimeResult { get; set; } = new(0f, 0f, 0f); + /// Raised when a team chat message arrives on this connection. public event EventHandler? TeamMessageReceived; @@ -75,6 +81,12 @@ public Task ConnectAsync(TimeSpan timeout, CancellationTok public Task GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(source.NextHeartbeat()); + public Task GetServerInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(InfoResult); + + public Task GetTimeAsync(TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(TimeResult); + public Task SendTeamMessageAsync(string message, CancellationToken cancellationToken) { SentMessages.Add(message); diff --git a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs new file mode 100644 index 00000000..77289f64 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs @@ -0,0 +1,165 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Discord.Notifications; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Connections.Tests.Fakes; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class ServerQueryTests +{ + private static (ServiceProvider Provider, ConnectionSupervisor Supervisor) CreateHarness( + FakeRustSocketSource source) + { + var protector = Substitute.For(); + protector.Unprotect(Arg.Any()).Returns(c => c.Arg()); + var dm = Substitute.For(); + + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(clock); + services.AddSingleton(protector); + services.AddSingleton(dm); + services.AddSingleton(); + + var cs = $"DataSource=serverquery-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + var keepAlive = new SqliteConnection(cs); + keepAlive.Open(); + using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) + { + seed.Database.Migrate(); + } + + services.AddSingleton(keepAlive); + services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(source); + services.AddSingleton(Options.Create(new ConnectionOptions + { + ConnectTimeout = TimeSpan.FromSeconds(1), + InitialRetryDelay = TimeSpan.FromMilliseconds(5), + MaxRetryDelay = TimeSpan.FromMilliseconds(20), + HeartbeatInterval = TimeSpan.FromMilliseconds(20), + HeartbeatTimeout = TimeSpan.FromMilliseconds(200), + })); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + return (provider, provider.GetRequiredService()); + } + + private static async Task SeedServerWithActiveAsync(ServiceProvider provider, ulong steamId) + { + using var scope = provider.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + ctx.RustServers.Add(server); + ctx.PlayerCredentials.Add(new PlayerCredential + { + GuildId = 10UL, + RustServerId = server.Id, + OwnerUserId = 1UL, + SteamId = steamId, + ProtectedPlayerToken = "123", + Status = CredentialStatus.Active, + }); + await ctx.SaveChangesAsync(); + return server.Id; + } + + [Fact] + public async Task GetServerInfo_ReturnsSnapshot_WhenConnected() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + source.LastConnection!.InfoResult = new ServerInfoSnapshot(5, 100, 2, null); + var snapshot = await supervisor.GetServerInfoAsync(10UL, serverId, cts.Token); + + Assert.NotNull(snapshot); + Assert.Equal(5, snapshot.Players); + Assert.Equal(100, snapshot.MaxPlayers); + Assert.Equal(2, snapshot.QueuedPlayers); + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task GetServerInfo_ReturnsNull_WhenNoLiveSocket() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + + var snapshot = await supervisor.GetServerInfoAsync(10UL, Guid.NewGuid(), CancellationToken.None); + + Assert.Null(snapshot); + } + + [Fact] + public async Task GetTime_ReturnsSnapshot_WhenConnected() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + source.LastConnection!.TimeResult = new ServerTimeSnapshot(12.5f, 7f, 19f); + var snapshot = await supervisor.GetTimeAsync(10UL, serverId, cts.Token); + + Assert.NotNull(snapshot); + Assert.Equal(12.5f, snapshot.TimeOfDay); + Assert.Equal(7f, snapshot.Sunrise); + Assert.Equal(19f, snapshot.Sunset); + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task GetTime_ReturnsNull_WhenNoLiveSocket() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + + var snapshot = await supervisor.GetTimeAsync(10UL, Guid.NewGuid(), CancellationToken.None); + + Assert.Null(snapshot); + } + + private static async Task WaitUntilAsync(Func condition, CancellationToken ct) + { + while (!condition()) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(10, ct); + } + } +} diff --git a/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs new file mode 100644 index 00000000..01b457e1 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs @@ -0,0 +1,62 @@ +using Microsoft.Data.Sqlite; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Persistence.Commands; + +namespace RustPlusBot.Persistence.Tests.Commands; + +public sealed class MuteStoreTests +{ + private static (MuteStore Store, BotDbContext Context, SqliteConnection Conn) Create() + { + var (context, connection) = SqliteContextFixture.Create(); + return (new MuteStore(context), context, connection); + } + + private static async Task SeedServerAsync(BotDbContext context) + { + var server = new RustServer + { + GuildId = 1UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + return server.Id; + } + + [Fact] + public async Task GetMuted_DefaultsFalse_WhenNoRow() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + Assert.False(await store.GetMutedAsync(1UL, serverId, CancellationToken.None)); + } + + [Fact] + public async Task GetPrefix_DefaultsBang_WhenNoRow() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + Assert.Equal("!", await store.GetPrefixAsync(1UL, serverId, CancellationToken.None)); + } + + [Fact] + public async Task SetMuted_PersistsAndReadsBack() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + await store.SetMutedAsync(1UL, serverId, true, CancellationToken.None); + Assert.True(await store.GetMutedAsync(1UL, serverId, CancellationToken.None)); + + await store.SetMutedAsync(1UL, serverId, false, CancellationToken.None); + Assert.False(await store.GetMutedAsync(1UL, serverId, CancellationToken.None)); + } +} diff --git a/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs new file mode 100644 index 00000000..483bea53 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Domain.Commands; +using RustPlusBot.Domain.Servers; + +namespace RustPlusBot.Persistence.Tests.Commands; + +public sealed class ServerCommandSettingsSchemaTests +{ + [Fact] + public async Task ServerCommandSettings_RoundTrips_PrefixAndMuted() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + var server = new RustServer + { + GuildId = 1UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + var serverId = server.Id; + context.ServerCommandSettings.Add(new ServerCommandSettings + { + ServerId = serverId, GuildId = 1UL, Prefix = "?", Muted = true, + }); + await context.SaveChangesAsync(); + + var read = await context.ServerCommandSettings.SingleAsync(s => s.ServerId == serverId); + Assert.Equal("?", read.Prefix); + Assert.True(read.Muted); + } + + [Fact] + public async Task RemovingServer_CascadeDeletesItsCommandSettings() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + var server = new RustServer + { + GuildId = 1UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + context.ServerCommandSettings.Add(new ServerCommandSettings + { + ServerId = server.Id, GuildId = 1UL, Prefix = "!", Muted = false, + }); + await context.SaveChangesAsync(); + + context.RustServers.Remove(server); + await context.SaveChangesAsync(); + + Assert.Empty(await context.ServerCommandSettings.ToListAsync()); + } +}