From fabb39dd99b38585cfde9017ade63b9437527e8f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 19:24:18 +0200 Subject: [PATCH 01/16] feat(commands): scaffold Features.Commands project + CommandOptions Create RustPlusBot.Features.Commands (refs Abstractions, Persistence, Connections) and its test project; implement CommandOptions with DefaultPrefix and Cooldown defaults; wire both projects into the solution. Co-Authored-By: Claude Sonnet 4.6 --- RustPlusBot.slnx | 2 ++ .../CommandOptions.cs | 14 ++++++++++++++ .../RustPlusBot.Features.Commands.csproj | 14 ++++++++++++++ .../CommandOptionsTests.cs | 14 ++++++++++++++ ...RustPlusBot.Features.Commands.Tests.csproj | 19 +++++++++++++++++++ 5 files changed, 63 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/CommandOptions.cs create mode 100644 src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj create mode 100644 tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj 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.Features.Commands/CommandOptions.cs b/src/RustPlusBot.Features.Commands/CommandOptions.cs new file mode 100644 index 00000000..11d4d760 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/CommandOptions.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Features.Commands; + +/// Configuration for the in-game command framework. +public sealed class CommandOptions +{ + /// The configuration section name. + public const string SectionName = "Commands"; + + /// Fallback command prefix when a server has no configured prefix. + public string DefaultPrefix { get; set; } = "!"; + + /// Minimum interval between identical commands on one server. + public TimeSpan Cooldown { get; set; } = TimeSpan.FromSeconds(4); +} 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/tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs new file mode 100644 index 00000000..2d8c4096 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs @@ -0,0 +1,14 @@ +using RustPlusBot.Features.Commands; + +namespace RustPlusBot.Features.Commands.Tests; + +public sealed class CommandOptionsTests +{ + [Fact] + public void Defaults_AreSensible() + { + var options = new CommandOptions(); + Assert.Equal("!", options.DefaultPrefix); + Assert.True(options.Cooldown > TimeSpan.Zero); + } +} 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 @@ + + + + + + + + + + + + + + + + + + + From e84fb10b390c243318fe76f5fdfccee06b550d5a Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 19:48:02 +0200 Subject: [PATCH 02/16] feat(commands): add CommandLine parser Co-Authored-By: Claude Sonnet 4.6 --- .../Dispatching/CommandLine.cs | 39 ++++++++++++++ .../Dispatching/CommandLineTests.cs | 52 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Dispatching/CommandLine.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandLineTests.cs 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/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 _)); + } +} From 75251c685848ac575be7505c9914dd1ddbf05ba7 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 19:51:38 +0200 Subject: [PATCH 03/16] feat(commands): add per-(server,command) cooldown Co-Authored-By: Claude Sonnet 4.6 --- .../Dispatching/CommandCooldown.cs | 38 +++++++++++++ .../Dispatching/CommandCooldownTests.cs | 56 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs new file mode 100644 index 00000000..8167ea06 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs @@ -0,0 +1,38 @@ +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 ConcurrentDictionary<(Guid Server, string Name), DateTimeOffset> _last = new(); + private readonly TimeSpan _cooldown = options.Value.Cooldown; + + /// Returns true if the command may run now (and records the run); false if on cooldown. + /// The server id. + /// The lowercase command name. + public bool TryConsume(Guid serverId, string name) + { + var now = clock.UtcNow; + var key = (serverId, name); + var allowed = true; + _last.AddOrUpdate( + key, + now, + (_, previous) => + { + if (now - previous < _cooldown) + { + allowed = false; + return previous; + } + + return now; + }); + return allowed; + } +} 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..1c6e49e7 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs @@ -0,0 +1,56 @@ +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 sealed class TestClock : IClock + { + public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch; + } + + 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")); + } +} From 1960c8f117d7581c5b839b304bf554b5d7f337e1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 19:59:52 +0200 Subject: [PATCH 04/16] feat(persistence): add ServerCommandSettings + IMuteStore + CommandSettings migration Co-Authored-By: Claude Opus 4.8 --- .../Commands/ServerCommandSettings.cs | 17 + src/RustPlusBot.Persistence/BotDbContext.cs | 5 + .../Commands/IMuteStore.cs | 27 + .../Commands/MuteStore.cs | 51 ++ .../ServerCommandSettingsConfiguration.cs | 22 + ...20260616175850_CommandSettings.Designer.cs | 504 ++++++++++++++++++ .../20260616175850_CommandSettings.cs | 42 ++ .../Migrations/BotDbContextModelSnapshot.cs | 30 ++ .../PersistenceServiceCollectionExtensions.cs | 2 + .../Commands/MuteStoreTests.cs | 62 +++ .../ServerCommandSettingsSchemaTests.cs | 63 +++ 11 files changed, 825 insertions(+) create mode 100644 src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs create mode 100644 src/RustPlusBot.Persistence/Commands/IMuteStore.cs create mode 100644 src/RustPlusBot.Persistence/Commands/MuteStore.cs create mode 100644 src/RustPlusBot.Persistence/Configurations/ServerCommandSettingsConfiguration.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.Designer.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.cs create mode 100644 tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs create mode 100644 tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs 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.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..391283f9 --- /dev/null +++ b/src/RustPlusBot.Persistence/Commands/MuteStore.cs @@ -0,0 +1,51 @@ +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.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..0a68fd0a --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs @@ -0,0 +1,63 @@ +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()); + } +} From 60e054ace40a3c52fcad67435c2d9087ab348c51 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 20:13:10 +0200 Subject: [PATCH 05/16] feat(connections): add IRustServerQuery read seam + snapshot mapping Co-Authored-By: Claude Opus 4.8 --- .../ConnectionServiceCollectionExtensions.cs | 1 + .../Listening/IRustServerConnection.cs | 12 ++ .../Listening/IRustServerQuery.cs | 19 ++ .../Listening/RustPlusSocketSource.cs | 79 +++++++++ .../Listening/ServerInfoSnapshot.cs | 8 + .../Listening/ServerTimeSnapshot.cs | 7 + .../Supervisor/ConnectionSupervisor.cs | 28 ++- .../Fakes/FakeRustSocketSource.cs | 12 ++ .../ServerQueryTests.cs | 164 ++++++++++++++++++ 9 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs create mode 100644 src/RustPlusBot.Features.Connections/Listening/ServerInfoSnapshot.cs create mode 100644 src/RustPlusBot.Features.Connections/Listening/ServerTimeSnapshot.cs create mode 100644 tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs 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..4fba3a95 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); @@ -155,6 +155,32 @@ public async Task SendAsync( } } + /// + 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); + } + [LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")] private static partial void LogLoopFaulted(ILogger logger, Exception exception, Guid serverId); 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..8fc54b08 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs @@ -0,0 +1,164 @@ +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); + } + } +} From c577aaee34cd8757c2a22a2481f0dde6ba3b8ae2 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 20:23:07 +0200 Subject: [PATCH 06/16] feat(commands): add EN/FR command localizer + catalog Co-Authored-By: Claude Sonnet 4.6 --- .../CommandLocalizationCatalog.cs | 44 +++++++++++++ .../Localization/CommandLocalizer.cs | 62 +++++++++++++++++++ .../Localization/ICommandLocalizer.cs | 16 +++++ .../Localization/CommandLocalizerTests.cs | 24 +++++++ 4 files changed, 146 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs create mode 100644 src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs create mode 100644 src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Localization/CommandLocalizerTests.cs diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs new file mode 100644 index 00000000..2a198bc0 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs @@ -0,0 +1,44 @@ +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.uptime.ok.server"] = "Uptime: {0}, server: {1}", + ["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.uptime.ok.server"] = "Disponibilité : {0}, serveur : {1}", + ["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..5e0b6aae --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs @@ -0,0 +1,62 @@ +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/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")); +} From 762b0e61ace4283a413bdf1edcbb5bb332e47ca9 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 20:26:22 +0200 Subject: [PATCH 07/16] feat(commands): add ICommandHandler, CommandContext, BotUptime Co-Authored-By: Claude Sonnet 4.6 --- .../Dispatching/CommandContext.cs | 16 ++++++++++++++++ .../Dispatching/ICommandHandler.cs | 14 ++++++++++++++ .../Hosting/BotUptime.cs | 13 +++++++++++++ .../Hosting/BotUptimeTests.cs | 18 ++++++++++++++++++ 4 files changed, 61 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Dispatching/CommandContext.cs create mode 100644 src/RustPlusBot.Features.Commands/Dispatching/ICommandHandler.cs create mode 100644 src/RustPlusBot.Features.Commands/Hosting/BotUptime.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Hosting/BotUptimeTests.cs 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/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/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/tests/RustPlusBot.Features.Commands.Tests/Hosting/BotUptimeTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Hosting/BotUptimeTests.cs new file mode 100644 index 00000000..91d483d3 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Hosting/BotUptimeTests.cs @@ -0,0 +1,18 @@ +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Commands.Hosting; + +namespace RustPlusBot.Features.Commands.Tests.Hosting; + +public sealed class BotUptimeTests +{ + private sealed class TestClock : IClock { public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch; } + + [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); + } +} From 416ec1a7c5449bbeb276a1370122c8ea730b1226 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 20:29:27 +0200 Subject: [PATCH 08/16] feat(commands): add !mute / !unmute handlers Implements Task 8: MuteCommandHandler and UnmuteCommandHandler (internal sealed, primary-constructor), each calling IMuteStore.SetMutedAsync and returning a localized confirmation. Tests written first (TDD); 2 new + 19 prior = 21 passing. Co-Authored-By: Claude Opus 4.8 --- .../Handlers/MuteCommandHandler.cs | 22 +++++++++++ .../Handlers/UnmuteCommandHandler.cs | 22 +++++++++++ .../Handlers/MuteHandlersTests.cs | 39 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Handlers/MuteCommandHandler.cs create mode 100644 src/RustPlusBot.Features.Commands/Handlers/UnmuteCommandHandler.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Handlers/MuteHandlersTests.cs 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/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/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); + } +} From 236246d590adcd45563cf9bd9fa5465ff983a0f3 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 20:33:13 +0200 Subject: [PATCH 09/16] feat(commands): add !uptime / !pop / !wipe / !time handlers Co-Authored-By: Claude Opus 4.8 --- .../Formatting/DurationFormat.cs | 25 ++++ .../Handlers/PopCommandHandler.cs | 27 +++++ .../Handlers/TimeCommandHandler.cs | 35 ++++++ .../Handlers/UptimeCommandHandler.cs | 23 ++++ .../Handlers/WipeCommandHandler.cs | 35 ++++++ .../Formatting/DurationFormatTests.cs | 13 ++ .../Handlers/QueryHandlersTests.cs | 113 ++++++++++++++++++ 7 files changed, 271 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs create mode 100644 src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs create mode 100644 src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs create mode 100644 src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs create mode 100644 src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Formatting/DurationFormatTests.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs 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/PopCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs new file mode 100644 index 00000000..1c5a81f1 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs @@ -0,0 +1,27 @@ +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/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..12c1a1d5 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs @@ -0,0 +1,35 @@ +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/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/QueryHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs new file mode 100644 index 00000000..6c47d837 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs @@ -0,0 +1,113 @@ +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", []); + private sealed class TestClock : IClock { public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch; } + + [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); + } +} From b87c64dc1560d322514cef034a01d6fa3828311c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 20:41:40 +0200 Subject: [PATCH 10/16] feat(commands): add CommandDispatcher pipeline --- .../Dispatching/CommandDispatcher.cs | 105 ++++++++++++++++ .../Dispatching/CommandDispatcherTests.cs | 117 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs new file mode 100644 index 00000000..84573da8 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs @@ -0,0 +1,105 @@ +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 Dictionary _handlers; + private readonly CommandCooldown _cooldown; + private readonly IMuteStore _muteStore; + private readonly IWorkspaceStore _workspace; + private readonly ITeamChatSender _sender; + private readonly ILogger _logger; + + /// 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/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs new file mode 100644 index 00000000..61278cd4 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs @@ -0,0 +1,117 @@ +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 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"); + } + } + + 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); + } +} From 832e83ff619c06c496fa4c085ac1d92bd369da36 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 20:47:38 +0200 Subject: [PATCH 11/16] feat(commands): add CommandsHostedService consume loop --- .../Hosting/CommandsHostedService.cs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs diff --git a/src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs b/src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs new file mode 100644 index 00000000..9a6a9c9f --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs @@ -0,0 +1,92 @@ +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 + { + using var scope = scopeFactory.CreateScope(); + 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); +} From 052b6ab081b21f4ca38861c233e7639c0c2711f2 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 20:51:54 +0200 Subject: [PATCH 12/16] feat(commands): wire AddCommands DI + host registration Co-Authored-By: Claude Opus 4.8 --- .../CommandServiceCollectionExtensions.cs | 37 ++++++++++++++ src/RustPlusBot.Host/Program.cs | 7 +++ src/RustPlusBot.Host/RustPlusBot.Host.csproj | 1 + .../CommandRegistrationTests.cs | 48 +++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs 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.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index a18dd193..e09457c7 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,12 @@ .ValidateOnStart(); builder.Services.AddConnections(); builder.Services.AddChat(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Commands")) + .Validate(static o => !string.IsNullOrWhiteSpace(o.DefaultPrefix), "Commands:DefaultPrefix must not be empty.") + .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/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"); + } +} From df8c1882545fd1ddb08d0ce4a0ff75d3eea85a64 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 21:00:00 +0200 Subject: [PATCH 13/16] feat(chat): gate Discord->game relay on mute state Co-Authored-By: Claude Opus 4.8 --- .../Inbound/TeamChatInboundProcessor.cs | 18 +++++++- .../Hosting/CommandsHostedService.cs | 9 ++-- .../ChatRegistrationTests.cs | 5 +++ .../TeamChatInboundProcessorTests.cs | 42 +++++++++++++++---- 4 files changed, 61 insertions(+), 13 deletions(-) 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/Hosting/CommandsHostedService.cs b/src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs index 9a6a9c9f..9f4c4198 100644 --- a/src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs +++ b/src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs @@ -56,9 +56,12 @@ private async Task ConsumeAsync(CancellationToken cancellationToken) { try { - using var scope = scopeFactory.CreateScope(); - var dispatcher = scope.ServiceProvider.GetRequiredService(); - await dispatcher.DispatchAsync(evt, cancellationToken).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var dispatcher = scope.ServiceProvider.GetRequiredService(); + await dispatcher.DispatchAsync(evt, cancellationToken).ConfigureAwait(false); + } } catch (OperationCanceledException) { 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()); + } } From 8e595aa64a391e41c55c89142641d9f1d838055e Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 21:08:18 +0200 Subject: [PATCH 14/16] style: apply ReSharper ReformatAndReorder to 3b files --- .../Dispatching/CommandCooldown.cs | 2 +- .../Dispatching/CommandDispatcher.cs | 18 ++++--- .../Handlers/PopCommandHandler.cs | 3 +- .../Handlers/WipeCommandHandler.cs | 9 ++-- .../Localization/CommandLocalizer.cs | 1 - .../Supervisor/ConnectionSupervisor.cs | 54 ++++++++++--------- .../Commands/MuteStore.cs | 13 +++-- .../Dispatching/CommandCooldownTests.cs | 15 +++--- .../Dispatching/CommandDispatcherTests.cs | 46 +++++++++------- .../Handlers/QueryHandlersTests.cs | 11 +++- .../Hosting/BotUptimeTests.cs | 7 ++- .../ServerQueryTests.cs | 3 +- .../ServerCommandSettingsSchemaTests.cs | 10 +--- 13 files changed, 112 insertions(+), 80 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs index 8167ea06..2bbefd48 100644 --- a/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs +++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs @@ -9,8 +9,8 @@ namespace RustPlusBot.Features.Commands.Dispatching; /// The command options carrying the cooldown window. internal sealed class CommandCooldown(IClock clock, IOptions options) { - private readonly ConcurrentDictionary<(Guid Server, string Name), DateTimeOffset> _last = new(); 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. diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs index 84573da8..61c6d2b4 100644 --- a/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs +++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs @@ -9,14 +9,18 @@ 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 static readonly HashSet MuteExempt = new(StringComparer.Ordinal) + { + "mute", "unmute" + }; - private readonly Dictionary _handlers; private readonly CommandCooldown _cooldown; + + private readonly Dictionary _handlers; + private readonly ILogger _logger; private readonly IMuteStore _muteStore; - private readonly IWorkspaceStore _workspace; private readonly ITeamChatSender _sender; - private readonly ILogger _logger; + private readonly IWorkspaceStore _workspace; /// Initializes the dispatcher. /// The available command handlers. @@ -55,7 +59,8 @@ public async Task DispatchAsync(TeamMessageReceivedEvent evt, CancellationToken return; } - var prefix = await _muteStore.GetPrefixAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + 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)) { @@ -75,7 +80,8 @@ await _muteStore.GetMutedAsync(evt.GuildId, evt.ServerId, cancellationToken).Con } 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); + var context = new CommandContext(evt.GuildId, evt.ServerId, culture, evt.SenderSteamId, evt.SenderName, + line.Args); string? reply; try diff --git a/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs index 1c5a81f1..0353d34c 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs @@ -16,7 +16,8 @@ internal sealed class PopCommandHandler(IRustServerQuery query, ICommandLocalize public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - var info = await query.GetServerInfoAsync(context.GuildId, context.ServerId, cancellationToken).ConfigureAwait(false); + var info = await query.GetServerInfoAsync(context.GuildId, context.ServerId, cancellationToken) + .ConfigureAwait(false); if (info is null) { return localizer.Get("command.notconnected", context.Culture); diff --git a/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs index 12c1a1d5..b823260a 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs @@ -10,7 +10,8 @@ namespace RustPlusBot.Features.Commands.Handlers; /// 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 +internal sealed class WipeCommandHandler(IRustServerQuery query, ICommandLocalizer localizer, IClock clock) + : ICommandHandler { /// public string Name => "wipe"; @@ -19,7 +20,8 @@ internal sealed class WipeCommandHandler(IRustServerQuery query, ICommandLocaliz public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - var info = await query.GetServerInfoAsync(context.GuildId, context.ServerId, cancellationToken).ConfigureAwait(false); + var info = await query.GetServerInfoAsync(context.GuildId, context.ServerId, cancellationToken) + .ConfigureAwait(false); if (info is null) { return localizer.Get("command.notconnected", context.Culture); @@ -30,6 +32,7 @@ internal sealed class WipeCommandHandler(IRustServerQuery query, ICommandLocaliz return localizer.Get("command.wipe.unknown", context.Culture); } - return localizer.Get("command.wipe.ok", context.Culture, DurationFormat.Compact(clock.UtcNow - info.WipeTimeUtc.Value)); + return localizer.Get("command.wipe.ok", context.Culture, + DurationFormat.Compact(clock.UtcNow - info.WipeTimeUtc.Value)); } } diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs index 5e0b6aae..936a94d9 100644 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs @@ -7,7 +7,6 @@ namespace RustPlusBot.Features.Commands.Localization; /// The string catalog. internal sealed class CommandLocalizer(CommandLocalizationCatalog catalog) : ICommandLocalizer { - private const string FallbackCulture = "en"; /// diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 4fba3a95..1454528e 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -126,38 +126,22 @@ public async Task StopAllAsync() } /// - public async Task SendAsync( + public async Task GetServerInfoAsync( ulong guildId, Guid serverId, - string message, CancellationToken cancellationToken) { if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) { - return TeamChatSendResult.NotConnected; + return null; } - try - { - await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); - return TeamChatSendResult.Sent; - } - catch (OperationCanceledException) - { - throw; - } -#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed. - catch (Exception ex) -#pragma warning restore CA1031 - { - LogSendFailed(logger, ex, serverId); - return TeamChatSendResult.Failed; - } + return await live.Connection.GetServerInfoAsync(_options.HeartbeatTimeout, cancellationToken) + .ConfigureAwait(false); } /// - public async Task GetServerInfoAsync( - ulong guildId, + public async Task GetTimeAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) { @@ -166,19 +150,37 @@ public async Task SendAsync( return null; } - return await live.Connection.GetServerInfoAsync(_options.HeartbeatTimeout, cancellationToken) - .ConfigureAwait(false); + return await live.Connection.GetTimeAsync(_options.HeartbeatTimeout, cancellationToken).ConfigureAwait(false); } /// - public async Task GetTimeAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + public async Task SendAsync( + ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken) { if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) { - return null; + return TeamChatSendResult.NotConnected; } - return await live.Connection.GetTimeAsync(_options.HeartbeatTimeout, cancellationToken).ConfigureAwait(false); + try + { + await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); + return TeamChatSendResult.Sent; + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogSendFailed(logger, ex, serverId); + return TeamChatSendResult.Failed; + } } [LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")] diff --git a/src/RustPlusBot.Persistence/Commands/MuteStore.cs b/src/RustPlusBot.Persistence/Commands/MuteStore.cs index 391283f9..052766e7 100644 --- a/src/RustPlusBot.Persistence/Commands/MuteStore.cs +++ b/src/RustPlusBot.Persistence/Commands/MuteStore.cs @@ -17,7 +17,10 @@ public async Task GetMutedAsync(ulong guildId, Guid serverId, Cancellation } /// - public async Task SetMutedAsync(ulong guildId, Guid serverId, bool muted, CancellationToken cancellationToken = default) + 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) @@ -27,9 +30,7 @@ public async Task SetMutedAsync(ulong guildId, Guid serverId, bool muted, Cancel { context.ServerCommandSettings.Add(new ServerCommandSettings { - GuildId = guildId, - ServerId = serverId, - Muted = muted, + GuildId = guildId, ServerId = serverId, Muted = muted, }); } else @@ -41,7 +42,9 @@ public async Task SetMutedAsync(ulong guildId, Guid serverId, bool muted, Cancel } /// - public async Task GetPrefixAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) + 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) diff --git a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs index 1c6e49e7..9765e4a4 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs @@ -7,13 +7,11 @@ namespace RustPlusBot.Features.Commands.Tests.Dispatching; public sealed class CommandCooldownTests { - private sealed class TestClock : IClock - { - public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch; - } - private static CommandCooldown Make(TestClock clock) => - new(clock, Options.Create(new CommandOptions { Cooldown = TimeSpan.FromSeconds(4) })); + new(clock, Options.Create(new CommandOptions + { + Cooldown = TimeSpan.FromSeconds(4) + })); [Fact] public void FirstUse_IsAllowed() @@ -53,4 +51,9 @@ public void DifferentCommand_OrServer_IsIndependent() Assert.True(cd.TryConsume(server, "time")); Assert.True(cd.TryConsume(Guid.NewGuid(), "pop")); } + + 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 index 61278cd4..7c1b4974 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs @@ -12,21 +12,10 @@ namespace RustPlusBot.Features.Commands.Tests.Dispatching; public sealed class CommandDispatcherTests { - 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"); - } - } - private static (CommandDispatcher Sut, ITeamChatSender Sender, StubHandler Handler) Build( - bool muted = false, string prefix = "!", string handlerName = "pop") + bool muted = false, + string prefix = "!", + string handlerName = "pop") { var sender = Substitute.For(); var settings = Substitute.For(); @@ -35,7 +24,8 @@ private static (CommandDispatcher Sut, ITeamChatSender Sender, StubHandler Handl 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 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); @@ -59,7 +49,8 @@ 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()); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); } [Fact] @@ -76,7 +67,8 @@ 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()); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); } [Fact] @@ -85,7 +77,8 @@ 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()); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); } [Fact] @@ -114,4 +107,21 @@ public async Task Cooldown_DropsSecondCall() 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/Handlers/QueryHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs index 6c47d837..0f362a0f 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs @@ -12,7 +12,6 @@ public sealed class QueryHandlersTests { private static readonly ICommandLocalizer Loc = new CommandLocalizer(CommandLocalizationCatalog.Default); private static CommandContext Ctx() => new(1, Guid.NewGuid(), "en", 7, "alice", []); - private sealed class TestClock : IClock { public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch; } [Fact] public async Task Pop_FormatsPlayersMaxQueued() @@ -38,7 +37,10 @@ public async Task Pop_NotConnected_WhenNull() [Fact] public async Task Wipe_ReportsAgo() { - var clock = new TestClock { UtcNow = DateTimeOffset.UnixEpoch.AddDays(2) }; + var clock = new TestClock + { + UtcNow = DateTimeOffset.UnixEpoch.AddDays(2) + }; var query = Substitute.For(); var ctx = Ctx(); query.GetServerInfoAsync(ctx.GuildId, ctx.ServerId, Arg.Any()) @@ -110,4 +112,9 @@ public async Task Uptime_ReportsBotUptime() 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 index 91d483d3..428196d7 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Hosting/BotUptimeTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Hosting/BotUptimeTests.cs @@ -5,8 +5,6 @@ namespace RustPlusBot.Features.Commands.Tests.Hosting; public sealed class BotUptimeTests { - private sealed class TestClock : IClock { public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch; } - [Fact] public void Elapsed_IsDifferenceSinceConstruction() { @@ -15,4 +13,9 @@ public void Elapsed_IsDifferenceSinceConstruction() 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.Connections.Tests/ServerQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs index 8fc54b08..77289f64 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs @@ -20,7 +20,8 @@ namespace RustPlusBot.Features.Connections.Tests; public sealed class ServerQueryTests { - private static (ServiceProvider Provider, ConnectionSupervisor Supervisor) CreateHarness(FakeRustSocketSource source) + private static (ServiceProvider Provider, ConnectionSupervisor Supervisor) CreateHarness( + FakeRustSocketSource source) { var protector = Substitute.For(); protector.Unprotect(Arg.Any()).Returns(c => c.Arg()); diff --git a/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs index 0a68fd0a..483bea53 100644 --- a/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs @@ -22,10 +22,7 @@ public async Task ServerCommandSettings_RoundTrips_PrefixAndMuted() var serverId = server.Id; context.ServerCommandSettings.Add(new ServerCommandSettings { - ServerId = serverId, - GuildId = 1UL, - Prefix = "?", - Muted = true, + ServerId = serverId, GuildId = 1UL, Prefix = "?", Muted = true, }); await context.SaveChangesAsync(); @@ -48,10 +45,7 @@ public async Task RemovingServer_CascadeDeletesItsCommandSettings() context.RustServers.Add(server); context.ServerCommandSettings.Add(new ServerCommandSettings { - ServerId = server.Id, - GuildId = 1UL, - Prefix = "!", - Muted = false, + ServerId = server.Id, GuildId = 1UL, Prefix = "!", Muted = false, }); await context.SaveChangesAsync(); From 6ccf1048e41bd7b1505956b592b4ccca74dbccb4 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 21:16:25 +0200 Subject: [PATCH 15/16] refactor(commands): drop unused DefaultPrefix option + dead uptime.ok.server key Final-review cleanup: DefaultPrefix was bound/validated but never read (the per-server prefix lives in ServerCommandSettings with a "!" fallback in the store), and command.uptime.ok.server was an unused localization key. --- src/RustPlusBot.Features.Commands/CommandOptions.cs | 3 --- .../Localization/CommandLocalizationCatalog.cs | 2 -- src/RustPlusBot.Host/Program.cs | 1 - .../RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs | 3 +-- 4 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/CommandOptions.cs b/src/RustPlusBot.Features.Commands/CommandOptions.cs index 11d4d760..52458f0a 100644 --- a/src/RustPlusBot.Features.Commands/CommandOptions.cs +++ b/src/RustPlusBot.Features.Commands/CommandOptions.cs @@ -6,9 +6,6 @@ public sealed class CommandOptions /// The configuration section name. public const string SectionName = "Commands"; - /// Fallback command prefix when a server has no configured prefix. - public string DefaultPrefix { get; set; } = "!"; - /// Minimum interval between identical commands on one server. public TimeSpan Cooldown { get; set; } = TimeSpan.FromSeconds(4); } diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs index 2a198bc0..12de9522 100644 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs @@ -16,7 +16,6 @@ internal sealed class CommandLocalizationCatalog ["command.mute.done"] = "Bot muted.", ["command.unmute.done"] = "Bot unmuted.", ["command.uptime.ok"] = "Uptime: {0}", - ["command.uptime.ok.server"] = "Uptime: {0}, server: {1}", ["command.pop.ok"] = "Pop: {0}/{1} ({2} queued)", ["command.time.ok"] = "Time: {0} — {1}", ["command.time.day"] = "day", @@ -30,7 +29,6 @@ internal sealed class CommandLocalizationCatalog ["command.mute.done"] = "Bot mis en sourdine.", ["command.unmute.done"] = "Bot réactivé.", ["command.uptime.ok"] = "Disponibilité : {0}", - ["command.uptime.ok.server"] = "Disponibilité : {0}, serveur : {1}", ["command.pop.ok"] = "Population : {0}/{1} ({2} en file)", ["command.time.ok"] = "Heure : {0} — {1}", ["command.time.day"] = "jour", diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index e09457c7..fa2c633d 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -55,7 +55,6 @@ builder.Services.AddChat(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Commands")) - .Validate(static o => !string.IsNullOrWhiteSpace(o.DefaultPrefix), "Commands:DefaultPrefix must not be empty.") .Validate(static o => o.Cooldown > TimeSpan.Zero, "Commands:Cooldown must be positive.") .ValidateOnStart(); builder.Services.AddCommands(); diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs index 2d8c4096..2e010caf 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs @@ -8,7 +8,6 @@ public sealed class CommandOptionsTests public void Defaults_AreSensible() { var options = new CommandOptions(); - Assert.Equal("!", options.DefaultPrefix); - Assert.True(options.Cooldown > TimeSpan.Zero); + Assert.Equal(TimeSpan.FromSeconds(4), options.Cooldown); } } From 0615e22c1a57484a63f2c168ab2e0c036aa07a5e Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 16 Jun 2026 21:33:41 +0200 Subject: [PATCH 16/16] fix(commands): make CommandCooldown.TryConsume atomic under concurrency The closure-captured 'allowed' flag inside ConcurrentDictionary.AddOrUpdate's updateFactory was not an atomic gate: the factory can run concurrently/retry, so two threads racing at window-expiry could both observe the same 'previous' and both return true. Replace with a lock-free TryGetValue + TryAdd/TryUpdate CAS so exactly one caller wins. Addresses Copilot review on PR #9. Co-Authored-By: Claude Opus 4.8 --- .../Dispatching/CommandCooldown.cs | 37 +++++++++++++------ .../Dispatching/CommandCooldownTests.cs | 18 +++++++++ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs b/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs index 2bbefd48..5eacd821 100644 --- a/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs +++ b/src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs @@ -15,24 +15,39 @@ internal sealed class CommandCooldown(IClock clock, IOptions opt /// 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); - var allowed = true; - _last.AddOrUpdate( - key, - now, - (_, previous) => + + while (true) + { + if (!_last.TryGetValue(key, out var previous)) { - if (now - previous < _cooldown) + // No prior run: the thread that inserts first wins; others fall through to the update path. + if (_last.TryAdd(key, now)) { - allowed = false; - return previous; + return true; } - return now; - }); - return allowed; + 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/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs index 9765e4a4..543b6467 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs @@ -52,6 +52,24 @@ public void DifferentCommand_OrServer_IsIndependent() 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;