From 35257c94869a0153249832ef2b0a1bcc36685996 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 21:43:45 +0200 Subject: [PATCH 01/29] chore(workspace): scaffold Features.Workspace project and tests Co-Authored-By: Claude Sonnet 4.6 --- RustPlusBot.slnx | 2 ++ .../AssemblyInfo.cs | 3 ++ .../RustPlusBot.Features.Workspace.csproj | 19 ++++++++++++ .../Placeholder.cs | 7 +++++ ...ustPlusBot.Features.Workspace.Tests.csproj | 29 +++++++++++++++++++ 5 files changed, 60 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/AssemblyInfo.cs create mode 100644 src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Placeholder.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx index 56da1cc9..dce34291 100644 --- a/RustPlusBot.slnx +++ b/RustPlusBot.slnx @@ -4,10 +4,12 @@ + + diff --git a/src/RustPlusBot.Features.Workspace/AssemblyInfo.cs b/src/RustPlusBot.Features.Workspace/AssemblyInfo.cs new file mode 100644 index 00000000..cb4b19b7 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("RustPlusBot.Features.Workspace.Tests")] diff --git a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj new file mode 100644 index 00000000..25260cfc --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + + + + + + + + + + + + + + + diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Placeholder.cs b/tests/RustPlusBot.Features.Workspace.Tests/Placeholder.cs new file mode 100644 index 00000000..51c54fc2 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Placeholder.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Features.Workspace.Tests; + +public sealed class Placeholder +{ + [Fact] + public void ProjectCompiles() => Assert.True(true); +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj b/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj new file mode 100644 index 00000000..f5ecbc8e --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + false + + + + + + + + + + + + + + + + + + + + + + + + From 7e3d25ce731a47ac504456c23ab15447b75ac710 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 21:46:05 +0200 Subject: [PATCH 02/29] feat(domain): add ProvisionedCategory/Channel/Message entities --- .../Workspace/ProvisionedCategory.cs | 20 +++++++++++++ .../Workspace/ProvisionedChannel.cs | 23 +++++++++++++++ .../Workspace/ProvisionedMessage.cs | 29 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs create mode 100644 src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs create mode 100644 src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs new file mode 100644 index 00000000..48937caf --- /dev/null +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs @@ -0,0 +1,20 @@ +namespace RustPlusBot.Domain.Workspace; + +/// A Discord category the bot has provisioned. One per scope (global or per-server). +public sealed class ProvisionedCategory +{ + /// Surrogate primary key. + public Guid Id { get; set; } = Guid.NewGuid(); + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } + + /// The server this category belongs to, or null for the global category. + public Guid? RustServerId { get; set; } + + /// The provisioned Discord category snowflake. + public ulong DiscordCategoryId { get; set; } + + /// When the record was first created (UTC). + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs new file mode 100644 index 00000000..853a1890 --- /dev/null +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs @@ -0,0 +1,23 @@ +namespace RustPlusBot.Domain.Workspace; + +/// A Discord text channel the bot has provisioned, identified by its stable spec key. +public sealed class ProvisionedChannel +{ + /// Surrogate primary key. + public Guid Id { get; set; } = Guid.NewGuid(); + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } + + /// The server this channel belongs to, or null for a global channel. + public Guid? RustServerId { get; set; } + + /// The stable spec key (e.g. "information", "setup", "settings", "info"). + public string ChannelKey { get; set; } = string.Empty; + + /// The provisioned Discord channel snowflake. + public ulong DiscordChannelId { get; set; } + + /// When the record was first created (UTC). + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs new file mode 100644 index 00000000..5d1a2dd9 --- /dev/null +++ b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs @@ -0,0 +1,29 @@ +namespace RustPlusBot.Domain.Workspace; + +/// An anchored bot message, edited in place rather than re-posted. +public sealed class ProvisionedMessage +{ + /// Surrogate primary key. + public Guid Id { get; set; } = Guid.NewGuid(); + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } + + /// The server this message belongs to, or null for a global message. + public Guid? RustServerId { get; set; } + + /// The stable spec key (e.g. "information.main", "settings.main", "server.info"). + public string MessageKey { get; set; } = string.Empty; + + /// The channel the message lives in. + public ulong DiscordChannelId { get; set; } + + /// The anchored message snowflake. + public ulong DiscordMessageId { get; set; } + + /// When the record was first created (UTC). + public DateTimeOffset CreatedAt { get; set; } + + /// When the message was last edited in place (UTC). + public DateTimeOffset UpdatedAt { get; set; } +} From 934f79ba0d7350c8982183442b40c1390674f416 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 21:49:28 +0200 Subject: [PATCH 03/29] refactor: remove /server, /bind, and ChannelBinding (superseded by workspace provisioning) Co-Authored-By: Claude Sonnet 4.6 --- src/RustPlusBot.Discord/Modules/BindModule.cs | 42 ------- .../Modules/ServerModule.cs | 119 ------------------ src/RustPlusBot.Domain/Guilds/BoundFeature.cs | 17 --- .../Guilds/ChannelBinding.cs | 17 --- .../Bindings/BindingService.cs | 48 ------- .../Bindings/IBindingService.cs | 27 ---- src/RustPlusBot.Persistence/BotDbContext.cs | 4 - .../ChannelBindingConfiguration.cs | 19 --- .../PersistenceServiceCollectionExtensions.cs | 2 - .../Bindings/BindingServiceTests.cs | 49 -------- .../PersistenceRegistrationTests.cs | 2 - .../SqliteContextFixture.cs | 6 + 12 files changed, 6 insertions(+), 346 deletions(-) delete mode 100644 src/RustPlusBot.Discord/Modules/BindModule.cs delete mode 100644 src/RustPlusBot.Discord/Modules/ServerModule.cs delete mode 100644 src/RustPlusBot.Domain/Guilds/BoundFeature.cs delete mode 100644 src/RustPlusBot.Domain/Guilds/ChannelBinding.cs delete mode 100644 src/RustPlusBot.Persistence/Bindings/BindingService.cs delete mode 100644 src/RustPlusBot.Persistence/Bindings/IBindingService.cs delete mode 100644 src/RustPlusBot.Persistence/Configurations/ChannelBindingConfiguration.cs delete mode 100644 tests/RustPlusBot.Persistence.Tests/Bindings/BindingServiceTests.cs diff --git a/src/RustPlusBot.Discord/Modules/BindModule.cs b/src/RustPlusBot.Discord/Modules/BindModule.cs deleted file mode 100644 index 56b3f5ae..00000000 --- a/src/RustPlusBot.Discord/Modules/BindModule.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Discord; -using Discord.Interactions; -using Microsoft.Extensions.DependencyInjection; -using RustPlusBot.Domain.Guilds; -using RustPlusBot.Persistence.Bindings; - -namespace RustPlusBot.Discord.Modules; - -/// Binds a Discord channel to a bot feature. -/// Creates a short-lived DI scope per command. -public sealed class BindModule(IServiceScopeFactory scopeFactory) - : InteractionModuleBase -{ - /// Binds a channel to a bot feature. - /// Which feature this channel serves. - /// Target text channel. - [SlashCommand("bind", "Bind a channel to a bot feature")] - public async Task BindAsync( - [Summary("feature", "Which feature this channel serves")] - BoundFeature feature, - [Summary("channel", "Target text channel")] - ITextChannel channel) - { - if (Context.Guild is null) - { - await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); - return; - } - - var scope = scopeFactory.CreateAsyncScope(); - try - { - var service = scope.ServiceProvider.GetRequiredService(); - await service.BindAsync(Context.Guild.Id, feature, channel.Id).ConfigureAwait(false); - await RespondAsync($"Bound **{feature}** to <#{channel.Id}>.", ephemeral: true).ConfigureAwait(false); - } - finally - { - await scope.DisposeAsync().ConfigureAwait(false); - } - } -} diff --git a/src/RustPlusBot.Discord/Modules/ServerModule.cs b/src/RustPlusBot.Discord/Modules/ServerModule.cs deleted file mode 100644 index 52261cdf..00000000 --- a/src/RustPlusBot.Discord/Modules/ServerModule.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Globalization; -using System.Text; -using Discord; -using Discord.Interactions; -using Microsoft.Extensions.DependencyInjection; -using RustPlusBot.Persistence.Servers; - -namespace RustPlusBot.Discord.Modules; - -/// Guild-scoped management of Rust+ servers via slash commands. -/// Creates a short-lived DI scope per command. -[Group("server", "Manage this guild's Rust+ servers")] -public sealed class ServerModule(IServiceScopeFactory scopeFactory) - : InteractionModuleBase -{ - /// Adds a Rust+ server to this guild. - /// Display name. - /// Server host or ip. - /// Rust+ app port. - [SlashCommand("add", "Add a Rust+ server to this guild")] - public async Task AddAsync( - [Summary("name", "Display name")] string name, - [Summary("ip", "Server host or ip")] string ip, - [Summary("port", "Rust+ app port")] int port) - { - if (Context.Guild is null) - { - await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); - return; - } - - if (port is < 1 or > 65535) - { - await RespondAsync("Port must be between 1 and 65535.", ephemeral: true).ConfigureAwait(false); - return; - } - - var scope = scopeFactory.CreateAsyncScope(); - try - { - var service = scope.ServiceProvider.GetRequiredService(); - var server = await service.AddAsync(Context.Guild.Id, Context.User.Id, name, ip, port) - .ConfigureAwait(false); - await RespondAsync($"Added **{server.Name}** (`{server.Id}`).", ephemeral: true).ConfigureAwait(false); - } - finally - { - await scope.DisposeAsync().ConfigureAwait(false); - } - } - - /// Lists this guild's Rust+ servers. - [SlashCommand("list", "List this guild's Rust+ servers")] - public async Task ListAsync() - { - if (Context.Guild is null) - { - await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); - return; - } - - var scope = scopeFactory.CreateAsyncScope(); - try - { - var service = scope.ServiceProvider.GetRequiredService(); - var servers = await service.ListAsync(Context.Guild.Id).ConfigureAwait(false); - - if (servers.Count == 0) - { - await RespondAsync("No servers configured.", ephemeral: true).ConfigureAwait(false); - return; - } - - var builder = new StringBuilder(); - foreach (var server in servers) - { - builder.AppendLine(CultureInfo.InvariantCulture, - $"• **{server.Name}** — `{server.Ip}:{server.Port}` (`{server.Id}`)"); - } - - await RespondAsync(builder.ToString(), ephemeral: true).ConfigureAwait(false); - } - finally - { - await scope.DisposeAsync().ConfigureAwait(false); - } - } - - /// Removes a Rust+ server by id. - /// The server id from /server list. - [SlashCommand("remove", "Remove a Rust+ server by id")] - public async Task RemoveAsync([Summary("id", "The server id from /server list")] string id) - { - if (Context.Guild is null) - { - await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); - return; - } - - if (!Guid.TryParse(id, out var serverId)) - { - await RespondAsync("That is not a valid server id.", ephemeral: true).ConfigureAwait(false); - return; - } - - var scope = scopeFactory.CreateAsyncScope(); - try - { - var service = scope.ServiceProvider.GetRequiredService(); - var removed = await service.RemoveAsync(Context.Guild.Id, serverId).ConfigureAwait(false); - await RespondAsync(removed ? "Removed." : "No matching server found.", ephemeral: true) - .ConfigureAwait(false); - } - finally - { - await scope.DisposeAsync().ConfigureAwait(false); - } - } -} diff --git a/src/RustPlusBot.Domain/Guilds/BoundFeature.cs b/src/RustPlusBot.Domain/Guilds/BoundFeature.cs deleted file mode 100644 index 820296b0..00000000 --- a/src/RustPlusBot.Domain/Guilds/BoundFeature.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace RustPlusBot.Domain.Guilds; - -/// The bot feature a Discord channel is bound to. -public enum BoundFeature -{ - /// Team-chat bridge and in-game commands. - Chat = 0, - - /// Map render and live event notifications. - Events = 1, - - /// Smart switches / alarms / storage monitors. - Devices = 2, - - /// Camera stills and control. - Cameras = 3, -} diff --git a/src/RustPlusBot.Domain/Guilds/ChannelBinding.cs b/src/RustPlusBot.Domain/Guilds/ChannelBinding.cs deleted file mode 100644 index 667cb6f1..00000000 --- a/src/RustPlusBot.Domain/Guilds/ChannelBinding.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace RustPlusBot.Domain.Guilds; - -/// Maps a Discord channel to a bot feature within a guild. -public sealed class ChannelBinding -{ - /// Surrogate primary key. - public Guid Id { get; set; } = Guid.NewGuid(); - - /// The owning Discord guild snowflake. - public ulong GuildId { get; set; } - - /// The bound Discord channel snowflake. - public ulong ChannelId { get; set; } - - /// The feature this channel serves. - public BoundFeature Feature { get; set; } -} diff --git a/src/RustPlusBot.Persistence/Bindings/BindingService.cs b/src/RustPlusBot.Persistence/Bindings/BindingService.cs deleted file mode 100644 index 580adb69..00000000 --- a/src/RustPlusBot.Persistence/Bindings/BindingService.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using RustPlusBot.Domain.Guilds; - -namespace RustPlusBot.Persistence.Bindings; - -/// Guild-scoped management of channel-to-feature bindings. -/// The bot database context. -public sealed class BindingService(BotDbContext context) : IBindingService -{ - /// - public async Task BindAsync( - ulong guildId, - BoundFeature feature, - ulong channelId, - CancellationToken cancellationToken = default) - { - var existing = await context.ChannelBindings - .SingleOrDefaultAsync(b => b.GuildId == guildId && b.Feature == feature, cancellationToken) - .ConfigureAwait(false); - - if (existing is null) - { - context.ChannelBindings.Add(new ChannelBinding - { - GuildId = guildId, Feature = feature, ChannelId = channelId, - }); - } - else - { - existing.ChannelId = channelId; - } - - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - - /// - public async Task GetBoundChannelAsync( - ulong guildId, - BoundFeature feature, - CancellationToken cancellationToken = default) - { - var binding = await context.ChannelBindings - .SingleOrDefaultAsync(b => b.GuildId == guildId && b.Feature == feature, cancellationToken) - .ConfigureAwait(false); - - return binding?.ChannelId; - } -} diff --git a/src/RustPlusBot.Persistence/Bindings/IBindingService.cs b/src/RustPlusBot.Persistence/Bindings/IBindingService.cs deleted file mode 100644 index 5c49a47a..00000000 --- a/src/RustPlusBot.Persistence/Bindings/IBindingService.cs +++ /dev/null @@ -1,27 +0,0 @@ -using RustPlusBot.Domain.Guilds; - -namespace RustPlusBot.Persistence.Bindings; - -/// Guild-scoped management of channel-to-feature bindings (one channel per feature per guild). -public interface IBindingService -{ - /// Binds (or re-binds) a feature to a channel within a guild. - /// Owning Discord guild snowflake. - /// The feature to bind. - /// The Discord channel snowflake to bind it to. - /// A cancellation token. - /// A task that completes when the binding is saved. - Task BindAsync(ulong guildId, - BoundFeature feature, - ulong channelId, - CancellationToken cancellationToken = default); - - /// Returns the channel bound to a feature, or null if unbound. - /// Owning Discord guild snowflake. - /// The feature to look up. - /// A cancellation token. - /// The bound channel snowflake, or null if the feature is unbound in this guild. - Task GetBoundChannelAsync(ulong guildId, - BoundFeature feature, - CancellationToken cancellationToken = default); -} diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs index 0359f42d..50a8fc87 100644 --- a/src/RustPlusBot.Persistence/BotDbContext.cs +++ b/src/RustPlusBot.Persistence/BotDbContext.cs @@ -29,9 +29,6 @@ public sealed class BotDbContext(DbContextOptions options) : Disco /// Per-guild settings. public DbSet GuildSettings => Set(); - /// Channel-to-feature bindings. - public DbSet ChannelBindings => Set(); - /// Paired smart devices. public DbSet PairedEntities => Set(); @@ -47,7 +44,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder .ApplyConfiguration(new RustServerConfiguration()) .ApplyConfiguration(new PlayerCredentialConfiguration()) - .ApplyConfiguration(new ChannelBindingConfiguration()) .ApplyConfiguration(new ConnectionStateConfiguration()) .ApplyConfiguration(new GuildSettingsConfiguration()) .ApplyConfiguration(new PairedEntityConfiguration()) diff --git a/src/RustPlusBot.Persistence/Configurations/ChannelBindingConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ChannelBindingConfiguration.cs deleted file mode 100644 index 01ca2694..00000000 --- a/src/RustPlusBot.Persistence/Configurations/ChannelBindingConfiguration.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using RustPlusBot.Domain.Guilds; - -namespace RustPlusBot.Persistence.Configurations; - -internal sealed class ChannelBindingConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.HasKey(b => b.Id); - // One channel per feature per guild. - builder.HasIndex(b => new - { - b.GuildId, b.Feature - }).IsUnique(); - } -} diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs index 8b7edc51..06bf9804 100644 --- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Abstractions.Credentials; -using RustPlusBot.Persistence.Bindings; using RustPlusBot.Persistence.Credentials; using RustPlusBot.Persistence.Servers; @@ -28,7 +27,6 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi sp.GetRequiredService>().CreateDbContext()); services.AddScoped(); - services.AddScoped(); services.AddScoped(); return services; diff --git a/tests/RustPlusBot.Persistence.Tests/Bindings/BindingServiceTests.cs b/tests/RustPlusBot.Persistence.Tests/Bindings/BindingServiceTests.cs deleted file mode 100644 index 33584b96..00000000 --- a/tests/RustPlusBot.Persistence.Tests/Bindings/BindingServiceTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using RustPlusBot.Domain.Guilds; -using RustPlusBot.Persistence.Bindings; - -namespace RustPlusBot.Persistence.Tests.Bindings; - -public sealed class BindingServiceTests -{ - [Fact] - public async Task BindAsync_StoresChannelForFeature() - { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; - var service = new BindingService(context); - - await service.BindAsync(10UL, BoundFeature.Chat, 555UL); - - Assert.Equal(555UL, await service.GetBoundChannelAsync(10UL, BoundFeature.Chat)); - Assert.Null(await service.GetBoundChannelAsync(10UL, BoundFeature.Events)); - } - - [Fact] - public async Task BindAsync_RebindingFeature_ReplacesChannel() - { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; - var service = new BindingService(context); - - await service.BindAsync(10UL, BoundFeature.Chat, 555UL); - await service.BindAsync(10UL, BoundFeature.Chat, 777UL); - - Assert.Equal(777UL, await service.GetBoundChannelAsync(10UL, BoundFeature.Chat)); - } - - [Fact] - public async Task GetBoundChannelAsync_DoesNotLeakAcrossGuilds() - { - var (context, connection) = SqliteContextFixture.Create(); - await using var _ = context; - await using var __ = connection; - var service = new BindingService(context); - - await service.BindAsync(10UL, BoundFeature.Chat, 555UL); - - // The same feature in a different guild must not see guild 10's binding. - Assert.Null(await service.GetBoundChannelAsync(20UL, BoundFeature.Chat)); - } -} diff --git a/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs b/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs index b9a7aebc..7ad23bac 100644 --- a/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Persistence; -using RustPlusBot.Persistence.Bindings; using RustPlusBot.Persistence.Servers; namespace RustPlusBot.Persistence.Tests; @@ -26,7 +25,6 @@ public void AddBotPersistence_RegistersDbContextFactoryAndScopedServices() // Services that depend only on BotDbContext resolve from a scope. Assert.NotNull(scope.ServiceProvider.GetService()); - Assert.NotNull(scope.ServiceProvider.GetService()); // ICredentialStore is registered here, but its ICredentialProtector dependency is // supplied by the Host, so verify the registration descriptor rather than resolving it. diff --git a/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs b/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs index 3cb08e0b..91590da5 100644 --- a/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs +++ b/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs @@ -1,5 +1,6 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using RustPlusBot.Persistence; namespace RustPlusBot.Persistence.Tests; @@ -14,6 +15,11 @@ public static (BotDbContext Context, SqliteConnection Connection) Create() var options = new DbContextOptionsBuilder() .UseSqlite(connection) + // Suppress PendingModelChangesWarning while a migration for the latest model changes + // is pending (i.e., between removing types from the context and adding the migration + // that drops the corresponding table). The next task adds the migration; until then + // the in-memory test schema is still valid for all remaining tests. + .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) .Options; var context = new BotDbContext(options); From ea32dbaadd831c27f62d1bd93bf403bd01e02b0e Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 21:54:03 +0200 Subject: [PATCH 04/29] feat(persistence): provisioning schema + WorkspaceProvisioning migration Co-Authored-By: Claude Sonnet 4.6 --- src/RustPlusBot.Persistence/BotDbContext.cs | 15 +- .../ProvisionedCategoryConfiguration.cs | 20 + .../ProvisionedChannelConfiguration.cs | 21 + .../ProvisionedMessageConfiguration.cs | 21 + ...14195321_WorkspaceProvisioning.Designer.cs | 422 ++++++++++++++++++ .../20260614195321_WorkspaceProvisioning.cs | 151 +++++++ .../Migrations/BotDbContextModelSnapshot.cs | 145 +++++- .../SqliteContextFixture.cs | 6 - .../Workspace/ProvisioningSchemaTests.cs | 47 ++ 9 files changed, 819 insertions(+), 29 deletions(-) create mode 100644 src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs create mode 100644 src/RustPlusBot.Persistence/Configurations/ProvisionedChannelConfiguration.cs create mode 100644 src/RustPlusBot.Persistence/Configurations/ProvisionedMessageConfiguration.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260614195321_WorkspaceProvisioning.Designer.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260614195321_WorkspaceProvisioning.cs create mode 100644 tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs index 50a8fc87..cdb89621 100644 --- a/src/RustPlusBot.Persistence/BotDbContext.cs +++ b/src/RustPlusBot.Persistence/BotDbContext.cs @@ -6,6 +6,7 @@ using RustPlusBot.Domain.Events; using RustPlusBot.Domain.Guilds; using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Workspace; using RustPlusBot.Persistence.Configurations; namespace RustPlusBot.Persistence; @@ -35,6 +36,15 @@ public sealed class BotDbContext(DbContextOptions options) : Disco /// Per-guild event subscriptions. public DbSet EventSubscriptions => Set(); + /// Provisioned Discord categories (global + per-server). + public DbSet ProvisionedCategories => Set(); + + /// Provisioned Discord channels, keyed by spec key. + public DbSet ProvisionedChannels => Set(); + + /// Anchored bot messages, edited in place. + public DbSet ProvisionedMessages => Set(); + /// protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -47,6 +57,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .ApplyConfiguration(new ConnectionStateConfiguration()) .ApplyConfiguration(new GuildSettingsConfiguration()) .ApplyConfiguration(new PairedEntityConfiguration()) - .ApplyConfiguration(new EventSubscriptionConfiguration()); + .ApplyConfiguration(new EventSubscriptionConfiguration()) + .ApplyConfiguration(new ProvisionedCategoryConfiguration()) + .ApplyConfiguration(new ProvisionedChannelConfiguration()) + .ApplyConfiguration(new ProvisionedMessageConfiguration()); } } diff --git a/src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs new file mode 100644 index 00000000..a57a469b --- /dev/null +++ b/src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Workspace; + +namespace RustPlusBot.Persistence.Configurations; + +internal sealed class ProvisionedCategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.HasKey(c => c.Id); + builder.HasIndex(c => new { c.GuildId, c.RustServerId }).IsUnique(); + builder.HasOne() + .WithMany() + .HasForeignKey(c => c.RustServerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/RustPlusBot.Persistence/Configurations/ProvisionedChannelConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ProvisionedChannelConfiguration.cs new file mode 100644 index 00000000..f036c3dd --- /dev/null +++ b/src/RustPlusBot.Persistence/Configurations/ProvisionedChannelConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Workspace; + +namespace RustPlusBot.Persistence.Configurations; + +internal sealed class ProvisionedChannelConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.HasKey(c => c.Id); + builder.Property(c => c.ChannelKey).IsRequired().HasMaxLength(64); + builder.HasIndex(c => new { c.GuildId, c.RustServerId, c.ChannelKey }).IsUnique(); + builder.HasOne() + .WithMany() + .HasForeignKey(c => c.RustServerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/RustPlusBot.Persistence/Configurations/ProvisionedMessageConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ProvisionedMessageConfiguration.cs new file mode 100644 index 00000000..1b71c3d9 --- /dev/null +++ b/src/RustPlusBot.Persistence/Configurations/ProvisionedMessageConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Workspace; + +namespace RustPlusBot.Persistence.Configurations; + +internal sealed class ProvisionedMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.HasKey(m => m.Id); + builder.Property(m => m.MessageKey).IsRequired().HasMaxLength(64); + builder.HasIndex(m => new { m.GuildId, m.RustServerId, m.MessageKey }).IsUnique(); + builder.HasOne() + .WithMany() + .HasForeignKey(m => m.RustServerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260614195321_WorkspaceProvisioning.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260614195321_WorkspaceProvisioning.Designer.cs new file mode 100644 index 00000000..3d655403 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260614195321_WorkspaceProvisioning.Designer.cs @@ -0,0 +1,422 @@ +// +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("20260614195321_WorkspaceProvisioning")] + partial class WorkspaceProvisioning + { + /// + 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.Connections.ConnectionState", b => + { + b.Property("RustServerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveCredentialId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("IsHealthy") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("RustServerId"); + + b.ToTable("ConnectionStates"); + }); + + 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("ProtectedFcmCredentials") + .IsRequired() + .HasColumnType("TEXT"); + + 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("GuildId", "RustServerId"); + + 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.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.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/20260614195321_WorkspaceProvisioning.cs b/src/RustPlusBot.Persistence/Migrations/20260614195321_WorkspaceProvisioning.cs new file mode 100644 index 00000000..1421ba29 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260614195321_WorkspaceProvisioning.cs @@ -0,0 +1,151 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class WorkspaceProvisioning : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChannelBindings"); + + migrationBuilder.CreateTable( + name: "ProvisionedCategories", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + RustServerId = table.Column(type: "TEXT", nullable: true), + DiscordCategoryId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProvisionedCategories", x => x.Id); + table.ForeignKey( + name: "FK_ProvisionedCategories_RustServers_RustServerId", + column: x => x.RustServerId, + principalTable: "RustServers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ProvisionedChannels", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + RustServerId = table.Column(type: "TEXT", nullable: true), + ChannelKey = table.Column(type: "TEXT", maxLength: 64, nullable: false), + DiscordChannelId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProvisionedChannels", x => x.Id); + table.ForeignKey( + name: "FK_ProvisionedChannels_RustServers_RustServerId", + column: x => x.RustServerId, + principalTable: "RustServers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ProvisionedMessages", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + RustServerId = table.Column(type: "TEXT", nullable: true), + MessageKey = table.Column(type: "TEXT", maxLength: 64, nullable: false), + DiscordChannelId = table.Column(type: "INTEGER", nullable: false), + DiscordMessageId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProvisionedMessages", x => x.Id); + table.ForeignKey( + name: "FK_ProvisionedMessages_RustServers_RustServerId", + column: x => x.RustServerId, + principalTable: "RustServers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProvisionedCategories_GuildId_RustServerId", + table: "ProvisionedCategories", + columns: new[] { "GuildId", "RustServerId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ProvisionedCategories_RustServerId", + table: "ProvisionedCategories", + column: "RustServerId"); + + migrationBuilder.CreateIndex( + name: "IX_ProvisionedChannels_GuildId_RustServerId_ChannelKey", + table: "ProvisionedChannels", + columns: new[] { "GuildId", "RustServerId", "ChannelKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ProvisionedChannels_RustServerId", + table: "ProvisionedChannels", + column: "RustServerId"); + + migrationBuilder.CreateIndex( + name: "IX_ProvisionedMessages_GuildId_RustServerId_MessageKey", + table: "ProvisionedMessages", + columns: new[] { "GuildId", "RustServerId", "MessageKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ProvisionedMessages_RustServerId", + table: "ProvisionedMessages", + column: "RustServerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProvisionedCategories"); + + migrationBuilder.DropTable( + name: "ProvisionedChannels"); + + migrationBuilder.DropTable( + name: "ProvisionedMessages"); + + migrationBuilder.CreateTable( + name: "ChannelBindings", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ChannelId = table.Column(type: "INTEGER", nullable: false), + Feature = table.Column(type: "INTEGER", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChannelBindings", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChannelBindings_GuildId_Feature", + table: "ChannelBindings", + columns: new[] { "GuildId", "Feature" }, + unique: true); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index e02aa67a..215fe39b 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -235,74 +235,151 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("EventSubscriptions"); }); - modelBuilder.Entity("RustPlusBot.Domain.Guilds.ChannelBinding", b => + 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("ChannelId") + 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.Property("Feature") + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + 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("GuildId", "Feature") + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId") .IsUnique(); - b.ToTable("ChannelBindings"); + b.ToTable("ProvisionedCategories"); }); - modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b => + 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("Culture") - .IsRequired() - .HasMaxLength(16) + b.Property("RustServerId") .HasColumnType("TEXT"); - b.HasKey("GuildId"); + b.HasKey("Id"); - b.ToTable("GuildSettings"); + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "ChannelKey") + .IsUnique(); + + b.ToTable("ProvisionedChannels"); }); - modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("AddedByUserId") + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("DiscordMessageId") .HasColumnType("INTEGER"); b.Property("GuildId") .HasColumnType("INTEGER"); - b.Property("Ip") + b.Property("MessageKey") .IsRequired() - .HasMaxLength(255) + .HasMaxLength(64) .HasColumnType("TEXT"); - b.Property("Name") - .IsRequired() - .HasMaxLength(128) + b.Property("RustServerId") .HasColumnType("TEXT"); - b.Property("Port") - .HasColumnType("INTEGER"); + b.Property("UpdatedAt") + .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("GuildId"); + b.HasIndex("RustServerId"); - b.ToTable("RustServers"); + b.HasIndex("GuildId", "RustServerId", "MessageKey") + .IsUnique(); + + b.ToTable("ProvisionedMessages"); }); modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => @@ -312,6 +389,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("ParentId") .OnDelete(DeleteBehavior.Restrict); }); + + 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/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs b/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs index 91590da5..3cb08e0b 100644 --- a/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs +++ b/tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs @@ -1,6 +1,5 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; using RustPlusBot.Persistence; namespace RustPlusBot.Persistence.Tests; @@ -15,11 +14,6 @@ public static (BotDbContext Context, SqliteConnection Connection) Create() var options = new DbContextOptionsBuilder() .UseSqlite(connection) - // Suppress PendingModelChangesWarning while a migration for the latest model changes - // is pending (i.e., between removing types from the context and adding the migration - // that drops the corresponding table). The next task adds the migration; until then - // the in-memory test schema is still valid for all remaining tests. - .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) .Options; var context = new BotDbContext(options); diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs new file mode 100644 index 00000000..0c9fcf56 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Workspace; + +namespace RustPlusBot.Persistence.Tests.Workspace; + +public sealed class ProvisioningSchemaTests +{ + [Fact] + public async Task RemovingServer_CascadeDeletesItsProvisioningRows() + { + 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 = 1 }; + context.RustServers.Add(server); + context.ProvisionedCategories.Add(new ProvisionedCategory + { + GuildId = 1UL, RustServerId = server.Id, DiscordCategoryId = 100UL, CreatedAt = DateTimeOffset.UnixEpoch, + }); + await context.SaveChangesAsync(); + + context.RustServers.Remove(server); + await context.SaveChangesAsync(); + + Assert.Empty(await context.ProvisionedCategories.ToListAsync()); + } + + [Fact] + public async Task GlobalCategory_HasNullServerId_AndPersists() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + context.ProvisionedCategories.Add(new ProvisionedCategory + { + GuildId = 1UL, RustServerId = null, DiscordCategoryId = 200UL, CreatedAt = DateTimeOffset.UnixEpoch, + }); + await context.SaveChangesAsync(); + + var loaded = await context.ProvisionedCategories.SingleAsync(); + Assert.Null(loaded.RustServerId); + Assert.Equal(200UL, loaded.DiscordCategoryId); + } +} From df33bdf88e89dd692274b28528576a7da5b70075 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 21:59:07 +0200 Subject: [PATCH 05/29] feat(persistence): IWorkspaceStore + WorkspaceStore upsert/teardown queries Co-Authored-By: Claude Sonnet 4.6 --- .../PersistenceServiceCollectionExtensions.cs | 2 + .../Workspace/IWorkspaceStore.cs | 73 ++++++++ .../Workspace/WorkspaceStore.cs | 156 ++++++++++++++++++ .../Workspace/WorkspaceStoreTests.cs | 101 ++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs create mode 100644 src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs create mode 100644 tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs index 06bf9804..5ae42e94 100644 --- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Persistence.Credentials; using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Persistence; @@ -28,6 +29,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs b/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs new file mode 100644 index 00000000..19023c9f --- /dev/null +++ b/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs @@ -0,0 +1,73 @@ +using RustPlusBot.Domain.Workspace; + +namespace RustPlusBot.Persistence.Workspace; + +/// Persistence for the bot's provisioned categories, channels, and anchored messages. +public interface IWorkspaceStore +{ + /// Gets the category for a scope ( null = global), or null. + /// The Discord guild snowflake. + /// The Rust server id, or null for the global scope. + /// A cancellation token. + /// The matching category, or null if none exists. + Task GetCategoryAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default); + + /// Upserts the category for its scope (keyed by guild + server). + /// The category to save. + /// A cancellation token. + Task SaveCategoryAsync(ProvisionedCategory category, CancellationToken cancellationToken = default); + + /// Gets all provisioned channels for a scope. + /// The Discord guild snowflake. + /// The Rust server id, or null for the global scope. + /// A cancellation token. + /// The provisioned channels for the scope. + Task> GetChannelsAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default); + + /// Upserts a channel for its scope (keyed by guild + server + channel key). + /// The channel to save. + /// A cancellation token. + Task SaveChannelAsync(ProvisionedChannel channel, CancellationToken cancellationToken = default); + + /// Gets the anchored message for a scope + message key, or null. + /// The Discord guild snowflake. + /// The Rust server id, or null for the global scope. + /// The stable spec key (e.g. "information.main"). + /// A cancellation token. + /// The matching message, or null if none exists. + Task GetMessageAsync(ulong guildId, Guid? serverId, string messageKey, CancellationToken cancellationToken = default); + + /// Upserts an anchored message (keyed by guild + server + message key). + /// The message to save. + /// A cancellation token. + Task SaveMessageAsync(ProvisionedMessage message, CancellationToken cancellationToken = default); + + /// Gets a guild's BCP-47 culture, defaulting to "en". + /// The Discord guild snowflake. + /// A cancellation token. + /// The guild's culture tag, or "en" if not set. + Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken = default); + + /// Sets a guild's culture (upserting the GuildSettings row). + /// The Discord guild snowflake. + /// The BCP-47 culture tag to set. + /// A cancellation token. + Task SetCultureAsync(ulong guildId, string culture, CancellationToken cancellationToken = default); + + /// Deletes all provisioning rows (category + channels + messages) for one scope. + /// The Discord guild snowflake. + /// The Rust server id, or null for the global scope. + /// A cancellation token. + Task DeleteScopeAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default); + + /// Gets every provisioned category in a guild (all scopes). + /// The Discord guild snowflake. + /// A cancellation token. + /// All provisioned categories for the guild. + Task> GetAllCategoriesAsync(ulong guildId, CancellationToken cancellationToken = default); + + /// Gets the distinct guild ids that have any provisioned category (for startup reconcile). + /// A cancellation token. + /// The distinct guild snowflakes that have at least one provisioned category. + Task> GetProvisionedGuildIdsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs new file mode 100644 index 00000000..0fd66967 --- /dev/null +++ b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs @@ -0,0 +1,156 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Guilds; +using RustPlusBot.Domain.Workspace; + +namespace RustPlusBot.Persistence.Workspace; + +/// EF Core implementation of over . +/// The bot database context. +/// The clock used for create/update timestamps. +public sealed class WorkspaceStore(BotDbContext context, IClock clock) : IWorkspaceStore +{ + /// + public Task GetCategoryAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) => + context.ProvisionedCategories + .SingleOrDefaultAsync(c => c.GuildId == guildId && c.RustServerId == serverId, cancellationToken); + + /// + public async Task SaveCategoryAsync(ProvisionedCategory category, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(category); + var existing = await context.ProvisionedCategories + .SingleOrDefaultAsync(c => c.GuildId == category.GuildId && c.RustServerId == category.RustServerId, cancellationToken) + .ConfigureAwait(false); + + if (existing is null) + { + category.CreatedAt = clock.UtcNow; + context.ProvisionedCategories.Add(category); + } + else + { + existing.DiscordCategoryId = category.DiscordCategoryId; + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> GetChannelsAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) => + await context.ProvisionedChannels + .Where(c => c.GuildId == guildId && c.RustServerId == serverId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + /// + public async Task SaveChannelAsync(ProvisionedChannel channel, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channel); + var existing = await context.ProvisionedChannels + .SingleOrDefaultAsync( + c => c.GuildId == channel.GuildId && c.RustServerId == channel.RustServerId && c.ChannelKey == channel.ChannelKey, + cancellationToken) + .ConfigureAwait(false); + + if (existing is null) + { + channel.CreatedAt = clock.UtcNow; + context.ProvisionedChannels.Add(channel); + } + else + { + existing.DiscordChannelId = channel.DiscordChannelId; + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public Task GetMessageAsync(ulong guildId, Guid? serverId, string messageKey, CancellationToken cancellationToken = default) => + context.ProvisionedMessages + .SingleOrDefaultAsync(m => m.GuildId == guildId && m.RustServerId == serverId && m.MessageKey == messageKey, cancellationToken); + + /// + public async Task SaveMessageAsync(ProvisionedMessage message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + var existing = await context.ProvisionedMessages + .SingleOrDefaultAsync( + m => m.GuildId == message.GuildId && m.RustServerId == message.RustServerId && m.MessageKey == message.MessageKey, + cancellationToken) + .ConfigureAwait(false); + + if (existing is null) + { + message.CreatedAt = clock.UtcNow; + message.UpdatedAt = clock.UtcNow; + context.ProvisionedMessages.Add(message); + } + else + { + existing.DiscordChannelId = message.DiscordChannelId; + existing.DiscordMessageId = message.DiscordMessageId; + existing.UpdatedAt = clock.UtcNow; + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken = default) + { + var settings = await context.GuildSettings + .SingleOrDefaultAsync(s => s.GuildId == guildId, cancellationToken) + .ConfigureAwait(false); + return settings?.Culture ?? "en"; + } + + /// + public async Task SetCultureAsync(ulong guildId, string culture, CancellationToken cancellationToken = default) + { + var settings = await context.GuildSettings + .SingleOrDefaultAsync(s => s.GuildId == guildId, cancellationToken) + .ConfigureAwait(false); + + if (settings is null) + { + context.GuildSettings.Add(new GuildSettings { GuildId = guildId, Culture = culture }); + } + else + { + settings.Culture = culture; + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task DeleteScopeAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) + { + await context.ProvisionedMessages + .Where(m => m.GuildId == guildId && m.RustServerId == serverId) + .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await context.ProvisionedChannels + .Where(c => c.GuildId == guildId && c.RustServerId == serverId) + .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await context.ProvisionedCategories + .Where(c => c.GuildId == guildId && c.RustServerId == serverId) + .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> GetAllCategoriesAsync(ulong guildId, CancellationToken cancellationToken = default) => + await context.ProvisionedCategories + .Where(c => c.GuildId == guildId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + /// + public async Task> GetProvisionedGuildIdsAsync(CancellationToken cancellationToken = default) => + await context.ProvisionedCategories + .Select(c => c.GuildId) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); +} diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs new file mode 100644 index 00000000..bc5de39e --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs @@ -0,0 +1,101 @@ +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Workspace; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Persistence.Tests.Workspace; + +public sealed class WorkspaceStoreTests +{ + private sealed class FixedClock(DateTimeOffset now) : IClock + { + public DateTimeOffset UtcNow { get; } = now; + } + + private static WorkspaceStore NewStore(out BotDbContext context, out IDisposable cleanup) + { + var (ctx, connection) = SqliteContextFixture.Create(); + context = ctx; + cleanup = connection; + return new WorkspaceStore(ctx, new FixedClock(DateTimeOffset.UnixEpoch)); + } + + [Fact] + public async Task SaveCategory_IsUpsertByScope() + { + var store = NewStore(out _, out var cleanup); + using var _cleanup = cleanup; + + await store.SaveCategoryAsync(new ProvisionedCategory { GuildId = 1, RustServerId = null, DiscordCategoryId = 10 }); + await store.SaveCategoryAsync(new ProvisionedCategory { GuildId = 1, RustServerId = null, DiscordCategoryId = 20 }); + + var loaded = await store.GetCategoryAsync(1, null); + Assert.NotNull(loaded); + Assert.Equal(20UL, loaded!.DiscordCategoryId); + } + + [Fact] + public async Task SaveChannel_UpsertByKey_AndScopeIsolated() + { + var store = NewStore(out _, out var cleanup); + using var _cleanup = cleanup; + + await store.SaveChannelAsync(new ProvisionedChannel { GuildId = 1, RustServerId = null, ChannelKey = "information", DiscordChannelId = 5 }); + await store.SaveChannelAsync(new ProvisionedChannel { GuildId = 1, RustServerId = null, ChannelKey = "information", DiscordChannelId = 6 }); + await store.SaveChannelAsync(new ProvisionedChannel { GuildId = 2, RustServerId = null, ChannelKey = "information", DiscordChannelId = 7 }); + + var g1 = await store.GetChannelsAsync(1, null); + Assert.Single(g1); + Assert.Equal(6UL, g1[0].DiscordChannelId); + var g2 = await store.GetChannelsAsync(2, null); + Assert.Single(g2); + Assert.Equal(7UL, g2[0].DiscordChannelId); + } + + [Fact] + public async Task SaveMessage_UpsertByKey_SetsTimestamps() + { + var store = NewStore(out _, out var cleanup); + using var _cleanup = cleanup; + + await store.SaveMessageAsync(new ProvisionedMessage { GuildId = 1, MessageKey = "information.main", DiscordChannelId = 5, DiscordMessageId = 100 }); + await store.SaveMessageAsync(new ProvisionedMessage { GuildId = 1, MessageKey = "information.main", DiscordChannelId = 5, DiscordMessageId = 101 }); + + var loaded = await store.GetMessageAsync(1, null, "information.main"); + Assert.NotNull(loaded); + Assert.Equal(101UL, loaded!.DiscordMessageId); + Assert.Equal(DateTimeOffset.UnixEpoch, loaded.UpdatedAt); + } + + [Fact] + public async Task Culture_DefaultsToEn_AndRoundTrips() + { + var store = NewStore(out _, out var cleanup); + using var _cleanup = cleanup; + + Assert.Equal("en", await store.GetCultureAsync(1)); + await store.SetCultureAsync(1, "fr"); + Assert.Equal("fr", await store.GetCultureAsync(1)); + } + + [Fact] + public async Task DeleteScope_RemovesOnlyThatScope() + { + var store = NewStore(out var context, out var cleanup); + using var _cleanup = cleanup; + + // RustServerId is a FK to RustServers, so we must insert a real server first. + var server = new RustPlusBot.Domain.Servers.RustServer { GuildId = 1, Name = "S", Ip = "1.1.1.1", Port = 1 }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + + await store.SaveCategoryAsync(new ProvisionedCategory { GuildId = 1, RustServerId = null, DiscordCategoryId = 10 }); + await store.SaveChannelAsync(new ProvisionedChannel { GuildId = 1, RustServerId = null, ChannelKey = "information", DiscordChannelId = 5 }); + await store.SaveCategoryAsync(new ProvisionedCategory { GuildId = 1, RustServerId = server.Id, DiscordCategoryId = 11 }); + + await store.DeleteScopeAsync(1, null); + + Assert.Null(await store.GetCategoryAsync(1, null)); + Assert.Empty(await store.GetChannelsAsync(1, null)); + Assert.NotNull(await store.GetCategoryAsync(1, server.Id)); + } +} From 1b4e873c93140d67450aeb7d5c4c65f8b8180c6c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:02:12 +0200 Subject: [PATCH 06/29] feat(workspace): i18n localization seam with EN/FR catalog Co-Authored-By: Claude Sonnet 4.6 --- .../Localization/ILocalizer.cs | 16 +++++ .../Localization/LocalizationCatalog.cs | 52 ++++++++++++++++ .../Localization/Localizer.cs | 59 +++++++++++++++++++ .../Localization/LocalizerTests.cs | 44 ++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs create mode 100644 src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs create mode 100644 src/RustPlusBot.Features.Workspace/Localization/Localizer.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Localization/LocalizerTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs b/src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs new file mode 100644 index 00000000..379ed67d --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs @@ -0,0 +1,16 @@ +namespace RustPlusBot.Features.Workspace.Localization; + +/// Resolves localized strings by key and BCP-47 culture, falling back to English. +internal interface ILocalizer +{ + /// Gets the localized string for a key, or the key itself if not found. + /// The string key to resolve. + /// The BCP-47 culture tag (e.g. "en", "fr"). + string Get(string key, string culture); + + /// Gets the localized, -applied string. + /// The string key to resolve. + /// The BCP-47 culture tag (e.g. "en", "fr"). + /// Format arguments. + string Get(string key, string culture, params object[] args); +} diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs new file mode 100644 index 00000000..f780340f --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs @@ -0,0 +1,52 @@ +namespace RustPlusBot.Features.Workspace.Localization; + +/// The in-memory string catalog: culture -> (key -> value). English is the fallback. +internal sealed class LocalizationCatalog +{ + /// culture -> key -> value. + public required IReadOnlyDictionary> Strings { get; init; } + + /// The built-in EN/FR catalog. + public static LocalizationCatalog Default { get; } = new() + { + Strings = new Dictionary>(StringComparer.Ordinal) + { + ["en"] = new Dictionary(StringComparer.Ordinal) + { + ["category.global.name"] = "RustPlusBot", + ["channel.information.name"] = "information", + ["channel.setup.name"] = "setup", + ["channel.settings.name"] = "settings", + ["channel.info.name"] = "info", + ["information.title"] = "RustPlusBot", + ["information.body"] = "Connect your Rust+ account in #setup, then pair a server in-game to begin.", + ["information.servers"] = "Servers registered: {0}", + ["setup.title"] = "Connect your Rust+ account", + ["setup.body"] = "Account connection arrives in the next update. Once connected, pair a server in-game and its channels appear automatically.", + ["settings.title"] = "Settings", + ["settings.body"] = "Configure the bot for this server.", + ["settings.language.label"] = "Language", + ["server.info.title"] = "{0}", + ["server.info.endpoint"] = "Endpoint: {0}:{1}", + }, + ["fr"] = new Dictionary(StringComparer.Ordinal) + { + ["category.global.name"] = "RustPlusBot", + ["channel.information.name"] = "informations", + ["channel.setup.name"] = "configuration", + ["channel.settings.name"] = "parametres", + ["channel.info.name"] = "info", + ["information.title"] = "RustPlusBot", + ["information.body"] = "Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.", + ["information.servers"] = "Serveurs enregistres : {0}", + ["setup.title"] = "Connectez votre compte Rust+", + ["setup.body"] = "La connexion de compte arrive dans la prochaine mise a jour.", + ["settings.title"] = "Parametres", + ["settings.body"] = "Configurez le bot pour ce serveur.", + ["settings.language.label"] = "Langue", + ["server.info.title"] = "{0}", + ["server.info.endpoint"] = "Adresse : {0}:{1}", + }, + }, + }; +} diff --git a/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs b/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs new file mode 100644 index 00000000..4b2efe9f --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs @@ -0,0 +1,59 @@ +using System.Globalization; + +namespace RustPlusBot.Features.Workspace.Localization; + +/// Dictionary-backed with English fallback and region normalization. +/// The string catalog. +internal sealed class Localizer(LocalizationCatalog catalog) : ILocalizer +{ + 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/tests/RustPlusBot.Features.Workspace.Tests/Localization/LocalizerTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Localization/LocalizerTests.cs new file mode 100644 index 00000000..dd661149 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Localization/LocalizerTests.cs @@ -0,0 +1,44 @@ +using RustPlusBot.Features.Workspace.Localization; + +namespace RustPlusBot.Features.Workspace.Tests.Localization; + +public sealed class LocalizerTests +{ + private static Localizer NewLocalizer() => new(LocalizationCatalog.Default); + + [Fact] + public void Get_ReturnsCultureSpecificValue() + { + var localizer = NewLocalizer(); + Assert.Equal("information", localizer.Get("channel.information.name", "en")); + Assert.Equal("informations", localizer.Get("channel.information.name", "fr")); + } + + [Fact] + public void Get_FallsBackToEnglish_WhenCultureMissing() + { + var localizer = NewLocalizer(); + Assert.Equal("information", localizer.Get("channel.information.name", "de")); + } + + [Fact] + public void Get_ReturnsKey_WhenMissingEverywhere() + { + var localizer = NewLocalizer(); + Assert.Equal("nope.key", localizer.Get("nope.key", "en")); + } + + [Fact] + public void Get_NormalizesRegionVariants() + { + var localizer = NewLocalizer(); + Assert.Equal("information", localizer.Get("channel.information.name", "en-US")); + } + + [Fact] + public void Get_WithArgs_Formats() + { + var localizer = NewLocalizer(); + Assert.Contains("3", localizer.Get("information.servers", "en", 3), StringComparison.Ordinal); + } +} From d4b9f333909ef9a4cb4318bbb612f2464d43a9c7 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:04:55 +0200 Subject: [PATCH 07/29] feat(workspace): spec registry (channel/message specs, providers, renderer seam) --- .../Gateway/MessagePayload.cs | 9 +++++ .../Registry/ChannelPermissionProfile.cs | 11 ++++++ .../Registry/ChannelSpec.cs | 14 ++++++++ .../Registry/IChannelSpecProvider.cs | 8 +++++ .../Registry/IMessageRenderer.cs | 16 +++++++++ .../Registry/IMessageSpecProvider.cs | 8 +++++ .../Registry/IWorkspaceRegistry.cs | 15 ++++++++ .../Registry/MessageRenderContext.cs | 7 ++++ .../Registry/MessageSpec.cs | 7 ++++ .../Registry/WorkspaceRegistry.cs | 20 +++++++++++ .../Registry/WorkspaceScope.cs | 11 ++++++ .../Registry/WorkspaceRegistryTests.cs | 35 +++++++++++++++++++ 12 files changed, 161 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/ChannelPermissionProfile.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/IChannelSpecProvider.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/IMessageSpecProvider.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/IWorkspaceRegistry.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/WorkspaceScope.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs b/src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs new file mode 100644 index 00000000..6b9d998f --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs @@ -0,0 +1,9 @@ +using Discord; + +namespace RustPlusBot.Features.Workspace.Gateway; + +/// A renderable message: optional text, embed, and components. +/// Plain content, or null. +/// An embed, or null. +/// Message components (buttons/selects), or null. +internal sealed record MessagePayload(string? Text, Embed? Embed, MessageComponent? Components); diff --git a/src/RustPlusBot.Features.Workspace/Registry/ChannelPermissionProfile.cs b/src/RustPlusBot.Features.Workspace/Registry/ChannelPermissionProfile.cs new file mode 100644 index 00000000..5eab6c96 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/ChannelPermissionProfile.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Features.Workspace.Registry; + +/// The permission shape applied to a provisioned channel. +internal enum ChannelPermissionProfile +{ + /// Members can view but not send; the bot manages content. + ReadOnly = 0, + + /// Members can send (reserved for later chat/command channels). + Interactive = 1, +} diff --git a/src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs b/src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs new file mode 100644 index 00000000..37fc7283 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Features.Workspace.Registry; + +/// Declarative description of a channel the bot should keep provisioned. +/// Global or per-server. +/// Stable key (persisted as ChannelKey). +/// i18n key resolving to the channel name. +/// Permission profile to apply. +/// Sort order within the category. +internal sealed record ChannelSpec( + WorkspaceScope Scope, + string Key, + string NameKey, + ChannelPermissionProfile Permissions, + int Order); diff --git a/src/RustPlusBot.Features.Workspace/Registry/IChannelSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Registry/IChannelSpecProvider.cs new file mode 100644 index 00000000..a3b92499 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/IChannelSpecProvider.cs @@ -0,0 +1,8 @@ +namespace RustPlusBot.Features.Workspace.Registry; + +/// A subsystem's contribution of channel specs. Implementations are aggregated via DI. +internal interface IChannelSpecProvider +{ + /// The channel specs this subsystem contributes. + IEnumerable GetChannelSpecs(); +} diff --git a/src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs new file mode 100644 index 00000000..8f09def0 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs @@ -0,0 +1,16 @@ +using RustPlusBot.Features.Workspace.Gateway; + +namespace RustPlusBot.Features.Workspace.Registry; + +/// Renders the payload for one message key. Looked up by . +internal interface IMessageRenderer +{ + /// The message key this renderer produces (matches ). + string MessageKey { get; } + + /// Builds the current payload for the given context. + /// The render context. + /// A cancellation token. + /// The message payload to post or edit. + ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Workspace/Registry/IMessageSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Registry/IMessageSpecProvider.cs new file mode 100644 index 00000000..951bea2e --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/IMessageSpecProvider.cs @@ -0,0 +1,8 @@ +namespace RustPlusBot.Features.Workspace.Registry; + +/// A subsystem's contribution of message specs. Implementations are aggregated via DI. +internal interface IMessageSpecProvider +{ + /// The message specs this subsystem contributes. + IEnumerable GetMessageSpecs(); +} diff --git a/src/RustPlusBot.Features.Workspace/Registry/IWorkspaceRegistry.cs b/src/RustPlusBot.Features.Workspace/Registry/IWorkspaceRegistry.cs new file mode 100644 index 00000000..8fb1644a --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/IWorkspaceRegistry.cs @@ -0,0 +1,15 @@ +namespace RustPlusBot.Features.Workspace.Registry; + +/// Aggregated, ordered view of all contributed channel/message specs. +internal interface IWorkspaceRegistry +{ + /// Channel specs for a scope, ordered by . + /// The scope to filter by. + /// The ordered channel specs. + IReadOnlyList GetChannelSpecs(WorkspaceScope scope); + + /// Message specs for a scope. + /// The scope to filter by. + /// The message specs. + IReadOnlyList GetMessageSpecs(WorkspaceScope scope); +} diff --git a/src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs b/src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs new file mode 100644 index 00000000..50270bf1 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Features.Workspace.Registry; + +/// Context handed to a renderer to build a message payload. +/// The guild being rendered for. +/// The server scope, or null for global messages. +/// The guild's BCP-47 culture. +internal sealed record MessageRenderContext(ulong GuildId, Guid? ServerId, string Culture); diff --git a/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs b/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs new file mode 100644 index 00000000..ac690ba1 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Features.Workspace.Registry; + +/// Declarative description of an anchored message the bot keeps in a channel. +/// Global or per-server. +/// Stable key (persisted as MessageKey); also the renderer lookup key. +/// The of the channel it lives in. +internal sealed record MessageSpec(WorkspaceScope Scope, string Key, string ChannelKey); diff --git a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs new file mode 100644 index 00000000..fca40323 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs @@ -0,0 +1,20 @@ +namespace RustPlusBot.Features.Workspace.Registry; + +/// Aggregates contributed spec providers into ordered, scope-filtered views. +/// All channel spec providers. +/// All message spec providers. +internal sealed class WorkspaceRegistry( + IEnumerable channelProviders, + IEnumerable messageProviders) : IWorkspaceRegistry +{ + private readonly List _channels = channelProviders.SelectMany(p => p.GetChannelSpecs()).ToList(); + private readonly List _messages = messageProviders.SelectMany(p => p.GetMessageSpecs()).ToList(); + + /// + public IReadOnlyList GetChannelSpecs(WorkspaceScope scope) => + _channels.Where(s => s.Scope == scope).OrderBy(s => s.Order).ThenBy(s => s.Key, StringComparer.Ordinal).ToList(); + + /// + public IReadOnlyList GetMessageSpecs(WorkspaceScope scope) => + _messages.Where(s => s.Scope == scope).ToList(); +} diff --git a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceScope.cs b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceScope.cs new file mode 100644 index 00000000..54ae25ef --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceScope.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Features.Workspace.Registry; + +/// Which desired-state scope a spec belongs to. +internal enum WorkspaceScope +{ + /// The single global RustPlusBot category per guild. + Global = 0, + + /// A per-registered-server category. + PerServer = 1, +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs new file mode 100644 index 00000000..4e799379 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs @@ -0,0 +1,35 @@ +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Tests.Registry; + +public sealed class WorkspaceRegistryTests +{ + private sealed class ProviderA : IChannelSpecProvider + { + public IEnumerable GetChannelSpecs() => + [ + new(WorkspaceScope.Global, "b", "b.name", ChannelPermissionProfile.ReadOnly, 1), + new(WorkspaceScope.Global, "a", "a.name", ChannelPermissionProfile.ReadOnly, 0), + ]; + } + + private sealed class ProviderB : IChannelSpecProvider + { + public IEnumerable GetChannelSpecs() => + [ + new(WorkspaceScope.PerServer, "info", "info.name", ChannelPermissionProfile.ReadOnly, 0), + ]; + } + + [Fact] + public void ChannelSpecs_AggregateAcrossProviders_OrderedByOrder() + { + var registry = new WorkspaceRegistry([new ProviderA(), new ProviderB()], []); + + var global = registry.GetChannelSpecs(WorkspaceScope.Global); + Assert.Equal(["a", "b"], global.Select(s => s.Key)); + + var perServer = registry.GetChannelSpecs(WorkspaceScope.PerServer); + Assert.Equal(["info"], perServer.Select(s => s.Key)); + } +} From 26391f4911c90af2df8d6e1f7f163a9dd680295e Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:08:34 +0200 Subject: [PATCH 08/29] feat(workspace): IWorkspaceGateway seam + in-memory test fake Co-Authored-By: Claude Sonnet 4.6 --- .../Gateway/IWorkspaceGateway.cs | 99 +++++++++++++++++ .../Fakes/FakeWorkspaceGateway.cs | 105 ++++++++++++++++++ .../Fakes/FakeWorkspaceGatewayTests.cs | 22 ++++ 3 files changed, 226 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGatewayTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs new file mode 100644 index 00000000..f49ca96a --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs @@ -0,0 +1,99 @@ +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Gateway; + +/// Primitive Discord operations the reconciler orchestrates. Implemented over Discord.Net +/// in production and by an in-memory fake in tests. Existence checks are cache reads (sync); +/// message existence and mutations are async REST calls. +internal interface IWorkspaceGateway +{ + /// True if a category with this snowflake currently exists in the guild. + /// The snowflake ID of the guild. + /// The snowflake ID of the category to check. + bool CategoryExists(ulong guildId, ulong categoryId); + + /// Finds a category by name, or null. + /// The snowflake ID of the guild. + /// The name of the category to search for. + /// Token to cancel the operation. + /// The snowflake ID of the matching category, or null if not found. + Task FindCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken); + + /// Creates a category and returns its snowflake. + /// The snowflake ID of the guild. + /// The name to assign to the new category. + /// Token to cancel the operation. + /// The snowflake ID of the newly created category. + Task CreateCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken); + + /// True if a text channel with this snowflake currently exists in the guild. + /// The snowflake ID of the guild. + /// The snowflake ID of the channel to check. + bool ChannelExists(ulong guildId, ulong channelId); + + /// Finds a text channel by name under a category, or null. + /// The snowflake ID of the guild. + /// The snowflake ID of the parent category. + /// The name of the channel to search for. + /// Token to cancel the operation. + /// The snowflake ID of the matching channel, or null if not found. + Task FindChannelAsync(ulong guildId, ulong categoryId, string name, CancellationToken cancellationToken); + + /// Creates a text channel under a category with the given profile; returns its snowflake. + /// The snowflake ID of the guild. + /// The snowflake ID of the parent category. + /// The name to assign to the new channel. + /// The permission profile to apply to the channel. + /// Token to cancel the operation. + /// The snowflake ID of the newly created channel. + Task CreateChannelAsync(ulong guildId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken); + + /// Re-applies parent + name + permission profile to an existing channel (adopt/heal path). + /// The snowflake ID of the guild. + /// The snowflake ID of the channel to update. + /// The snowflake ID of the parent category to set. + /// The name to set on the channel. + /// The permission profile to re-apply. + /// Token to cancel the operation. + Task ApplyChannelSettingsAsync(ulong guildId, ulong channelId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken); + + /// True if the message still exists in the channel. + /// The snowflake ID of the guild. + /// The snowflake ID of the channel containing the message. + /// The snowflake ID of the message to check. + /// Token to cancel the operation. + /// True if the message exists; otherwise false. + Task MessageExistsAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken); + + /// Posts a new message and returns its snowflake. + /// The snowflake ID of the guild. + /// The snowflake ID of the channel to post into. + /// The content of the message to post. + /// Token to cancel the operation. + /// The snowflake ID of the newly posted message. + Task PostMessageAsync(ulong guildId, ulong channelId, MessagePayload payload, CancellationToken cancellationToken); + + /// Edits an existing message in place. + /// The snowflake ID of the guild. + /// The snowflake ID of the channel containing the message. + /// The snowflake ID of the message to edit. + /// The new content to apply to the message. + /// Token to cancel the operation. + Task EditMessageAsync(ulong guildId, ulong channelId, ulong messageId, MessagePayload payload, CancellationToken cancellationToken); + + /// Deletes a channel by snowflake (no-op if already gone). + /// The snowflake ID of the guild. + /// The snowflake ID of the channel to delete. + /// Token to cancel the operation. + Task DeleteChannelAsync(ulong guildId, ulong channelId, CancellationToken cancellationToken); + + /// Deletes a category by snowflake (no-op if already gone). + /// The snowflake ID of the guild. + /// The snowflake ID of the category to delete. + /// Token to cancel the operation. + Task DeleteCategoryAsync(ulong guildId, ulong categoryId, CancellationToken cancellationToken); + + /// Returns the names of required bot guild permissions that are missing (empty = all present). + /// The snowflake ID of the guild to check permissions for. + IReadOnlyList GetMissingBotPermissions(ulong guildId); +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs new file mode 100644 index 00000000..75aced2e --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs @@ -0,0 +1,105 @@ +using System.Collections.Concurrent; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Tests.Fakes; + +/// Deterministic in-memory for reconciler tests. +internal sealed class FakeWorkspaceGateway : IWorkspaceGateway +{ + private sealed record Category(ulong Id, string Name); + private sealed record Channel(ulong Id, ulong CategoryId, string Name, ChannelPermissionProfile Profile); + private sealed record Message(ulong Id, ulong ChannelId, MessagePayload Payload); + + private readonly ConcurrentDictionary _categories = new(); + private readonly ConcurrentDictionary _channels = new(); + private readonly ConcurrentDictionary _messages = new(); + private ulong _nextId = 1000; + + public IReadOnlyList MissingPermissions { get; set; } = []; + public int CreatedCategories { get; private set; } + public int CreatedChannels { get; private set; } + public int PostedMessages { get; private set; } + public int EditedMessages { get; private set; } + + private ulong NextId() => Interlocked.Increment(ref _nextId); + + public void ExternallyDeleteChannel(ulong channelId) => _channels.TryRemove(channelId, out _); + public void ExternallyDeleteCategory(ulong categoryId) => _categories.TryRemove(categoryId, out _); + public void ExternallyDeleteMessage(ulong messageId) => _messages.TryRemove(messageId, out _); + public MessagePayload? GetMessagePayload(ulong messageId) => _messages.TryGetValue(messageId, out var m) ? m.Payload : null; + public IReadOnlyCollection ChannelIds => _channels.Keys.ToList(); + public IReadOnlyCollection CategoryIds => _categories.Keys.ToList(); + public ChannelPermissionProfile ProfileOf(ulong channelId) => _channels[channelId].Profile; + + public bool CategoryExists(ulong guildId, ulong categoryId) => _categories.ContainsKey(categoryId); + + public Task FindCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken) + { + var match = _categories.Values.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(match is null ? (ulong?)null : match.Id); + } + + public Task CreateCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken) + { + var id = NextId(); + _categories[id] = new Category(id, name); + CreatedCategories++; + return Task.FromResult(id); + } + + public bool ChannelExists(ulong guildId, ulong channelId) => _channels.ContainsKey(channelId); + + public Task FindChannelAsync(ulong guildId, ulong categoryId, string name, CancellationToken cancellationToken) + { + var match = _channels.Values.FirstOrDefault(c => + c.CategoryId == categoryId && string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(match is null ? (ulong?)null : match.Id); + } + + public Task CreateChannelAsync(ulong guildId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken) + { + var id = NextId(); + _channels[id] = new Channel(id, categoryId, name, profile); + CreatedChannels++; + return Task.FromResult(id); + } + + public Task ApplyChannelSettingsAsync(ulong guildId, ulong channelId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken) + { + _channels[channelId] = new Channel(channelId, categoryId, name, profile); + return Task.CompletedTask; + } + + public Task MessageExistsAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken) => + Task.FromResult(_messages.ContainsKey(messageId)); + + public Task PostMessageAsync(ulong guildId, ulong channelId, MessagePayload payload, CancellationToken cancellationToken) + { + var id = NextId(); + _messages[id] = new Message(id, channelId, payload); + PostedMessages++; + return Task.FromResult(id); + } + + public Task EditMessageAsync(ulong guildId, ulong channelId, ulong messageId, MessagePayload payload, CancellationToken cancellationToken) + { + _messages[messageId] = new Message(messageId, channelId, payload); + EditedMessages++; + return Task.CompletedTask; + } + + public Task DeleteChannelAsync(ulong guildId, ulong channelId, CancellationToken cancellationToken) + { + _channels.TryRemove(channelId, out _); + return Task.CompletedTask; + } + + public Task DeleteCategoryAsync(ulong guildId, ulong categoryId, CancellationToken cancellationToken) + { + _categories.TryRemove(categoryId, out _); + return Task.CompletedTask; + } + + public IReadOnlyList GetMissingBotPermissions(ulong guildId) => MissingPermissions; +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGatewayTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGatewayTests.cs new file mode 100644 index 00000000..17f0e576 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGatewayTests.cs @@ -0,0 +1,22 @@ +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Features.Workspace.Tests.Fakes; + +namespace RustPlusBot.Features.Workspace.Tests.Fakes; + +public sealed class FakeWorkspaceGatewayTests +{ + [Fact] + public async Task CreateAndFind_RoundTrips() + { + var gateway = new FakeWorkspaceGateway(); + var categoryId = await gateway.CreateCategoryAsync(1, "RustPlusBot", default); + Assert.True(gateway.CategoryExists(1, categoryId)); + Assert.Equal(categoryId, await gateway.FindCategoryAsync(1, "rustplusbot", default)); + + var channelId = await gateway.CreateChannelAsync(1, categoryId, "information", ChannelPermissionProfile.ReadOnly, default); + Assert.Equal(channelId, await gateway.FindChannelAsync(1, categoryId, "information", default)); + + gateway.ExternallyDeleteChannel(channelId); + Assert.False(gateway.ChannelExists(1, channelId)); + } +} From 40c0296c33051bc49f7943ff87043d8f91486668 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:10:34 +0200 Subject: [PATCH 09/29] feat(workspace): per-guild ProvisioningLock --- .../Reconciler/IProvisioningLock.cs | 11 +++++++ .../Reconciler/ProvisioningLock.cs | 30 +++++++++++++++++++ .../Reconciler/ProvisioningLockTests.cs | 29 ++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Reconciler/IProvisioningLock.cs create mode 100644 src/RustPlusBot.Features.Workspace/Reconciler/ProvisioningLock.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ProvisioningLockTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/IProvisioningLock.cs b/src/RustPlusBot.Features.Workspace/Reconciler/IProvisioningLock.cs new file mode 100644 index 00000000..90f9749c --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Reconciler/IProvisioningLock.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Features.Workspace.Reconciler; + +/// Serializes reconciliation per guild so concurrent triggers cannot interleave. +internal interface IProvisioningLock +{ + /// Acquires the guild's lock; dispose the result to release. + /// The guild to lock. + /// A cancellation token. + /// A disposable handle that releases the lock on dispose. + Task AcquireAsync(ulong guildId, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/ProvisioningLock.cs b/src/RustPlusBot.Features.Workspace/Reconciler/ProvisioningLock.cs new file mode 100644 index 00000000..ad946914 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Reconciler/ProvisioningLock.cs @@ -0,0 +1,30 @@ +using System.Collections.Concurrent; + +namespace RustPlusBot.Features.Workspace.Reconciler; + +/// Per-guild -backed lock. Registered as a singleton. +internal sealed class ProvisioningLock : IProvisioningLock +{ + private readonly ConcurrentDictionary _locks = new(); + + /// + public async Task AcquireAsync(ulong guildId, CancellationToken cancellationToken = default) + { + var semaphore = _locks.GetOrAdd(guildId, static _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + return new Releaser(semaphore); + } + + private sealed class Releaser(SemaphoreSlim semaphore) : IDisposable + { + private int _released; + + public void Dispose() + { + if (Interlocked.Exchange(ref _released, 1) == 0) + { + semaphore.Release(); + } + } + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ProvisioningLockTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ProvisioningLockTests.cs new file mode 100644 index 00000000..d600b06f --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ProvisioningLockTests.cs @@ -0,0 +1,29 @@ +using RustPlusBot.Features.Workspace.Reconciler; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class ProvisioningLockTests +{ + [Fact] + public async Task SameGuild_SerializesHolders() + { + var sut = new ProvisioningLock(); + var handle = await sut.AcquireAsync(1); + + var second = sut.AcquireAsync(1); + Assert.False(second.IsCompleted); // blocked while first is held + + handle.Dispose(); + var secondHandle = await second; // now succeeds + secondHandle.Dispose(); + } + + [Fact] + public async Task DifferentGuilds_DoNotBlock() + { + var sut = new ProvisioningLock(); + using var a = await sut.AcquireAsync(1); + using var b = await sut.AcquireAsync(2); // does not block + Assert.NotNull(b); + } +} From 8ebf37f5b15d2c78c8727d31bb0eb89fa9b6972e Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:16:14 +0200 Subject: [PATCH 10/29] feat(workspace): WorkspaceReconciler converge engine + fresh provision Co-Authored-By: Claude Sonnet 4.6 --- .../Reconciler/IWorkspaceReconciler.cs | 18 ++ .../Reconciler/ReconcileResult.cs | 25 +++ .../Reconciler/WorkspaceReconciler.cs | 175 ++++++++++++++++++ .../Servers/IServerService.cs | 7 + .../Servers/ServerService.cs | 4 + .../Fakes/FakeWorkspaceStore.cs | 92 +++++++++ .../Reconciler/ReconcilerHarness.cs | 62 +++++++ .../WorkspaceReconcilerProvisionTests.cs | 45 +++++ 8 files changed, 428 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs create mode 100644 src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs create mode 100644 src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerProvisionTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs new file mode 100644 index 00000000..76bd63c3 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs @@ -0,0 +1,18 @@ +namespace RustPlusBot.Features.Workspace.Reconciler; + +/// Converges a guild's Discord workspace to the desired state described by the registry. +internal interface IWorkspaceReconciler +{ + /// Reconciles the global RustPlusBot category and its channels/messages. + /// The guild to reconcile. + /// A cancellation token. + /// The reconcile result. + Task ReconcileGlobalAsync(ulong guildId, CancellationToken cancellationToken = default); + + /// Reconciles a single server's category and its channels/messages. + /// The guild to reconcile. + /// The server to reconcile. + /// A cancellation token. + /// The reconcile result. + Task ReconcileServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs b/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs new file mode 100644 index 00000000..869154a4 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs @@ -0,0 +1,25 @@ +namespace RustPlusBot.Features.Workspace.Reconciler; + +/// Outcome of a reconcile. +internal enum ReconcileStatus +{ + /// The scope was converged. + Provisioned = 0, + + /// The bot lacks required guild permissions; nothing was changed. + MissingPermissions = 1, +} + +/// Result of a reconcile, including any missing permissions. +/// The outcome. +/// Missing permission names when . +internal sealed record ReconcileResult(ReconcileStatus Status, IReadOnlyList MissingPermissions) +{ + /// A successful provision result. + public static ReconcileResult Provisioned { get; } = new(ReconcileStatus.Provisioned, []); + + /// Builds a missing-permissions result. + /// The missing permission names. + /// A missing-permissions result. + public static ReconcileResult Missing(IReadOnlyList permissions) => new(ReconcileStatus.MissingPermissions, permissions); +} diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs new file mode 100644 index 00000000..33d67ed9 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs @@ -0,0 +1,175 @@ +using Microsoft.Extensions.Logging; +using RustPlusBot.Domain.Workspace; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Localization; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Reconciler; + +/// Desired-state reconciler. resolve -> adopt -> create, serialized per guild. +/// The workspace channel/message spec registry. +/// The Discord gateway adapter. +/// The provisioning persistence store. +/// All registered message renderers. +/// The server persistence service. +/// The localization service. +/// The per-guild provisioning lock. +/// The logger. +internal sealed class WorkspaceReconciler( + IWorkspaceRegistry registry, + IWorkspaceGateway gateway, + IWorkspaceStore store, + IEnumerable renderers, + IServerService servers, + ILocalizer localizer, + IProvisioningLock provisioningLock, + ILogger logger) : IWorkspaceReconciler +{ + private readonly Dictionary _renderers = + renderers.ToDictionary(r => r.MessageKey, StringComparer.Ordinal); + + /// + public async Task ReconcileGlobalAsync(ulong guildId, CancellationToken cancellationToken = default) + { + using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); + + var missing = gateway.GetMissingBotPermissions(guildId); + if (missing.Count > 0) + { + return ReconcileResult.Missing(missing); + } + + var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var categoryName = localizer.Get("category.global.name", culture); + await ReconcileScopeAsync(guildId, null, categoryName, culture, WorkspaceScope.Global, cancellationToken).ConfigureAwait(false); + return ReconcileResult.Provisioned; + } + + /// + public async Task ReconcileServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) + { + using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); + + var missing = gateway.GetMissingBotPermissions(guildId); + if (missing.Count > 0) + { + return ReconcileResult.Missing(missing); + } + + var server = await servers.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (server is null) + { + logger.LogWarning("ReconcileServer skipped: server {ServerId} not found in guild {GuildId}.", serverId, guildId); + return ReconcileResult.Provisioned; + } + + var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + await ReconcileScopeAsync(guildId, serverId, server.Name, culture, WorkspaceScope.PerServer, cancellationToken).ConfigureAwait(false); + return ReconcileResult.Provisioned; + } + + private async Task ReconcileScopeAsync(ulong guildId, Guid? serverId, string categoryName, string culture, WorkspaceScope scope, CancellationToken cancellationToken) + { + var categoryId = await EnsureCategoryAsync(guildId, serverId, categoryName, cancellationToken).ConfigureAwait(false); + var channelIds = await EnsureChannelsAsync(guildId, serverId, categoryId, culture, scope, cancellationToken).ConfigureAwait(false); + await EnsureMessagesAsync(guildId, serverId, channelIds, culture, scope, cancellationToken).ConfigureAwait(false); + } + + private async Task EnsureCategoryAsync(ulong guildId, Guid? serverId, string name, CancellationToken cancellationToken) + { + var record = await store.GetCategoryAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (record is not null && gateway.CategoryExists(guildId, record.DiscordCategoryId)) + { + return record.DiscordCategoryId; + } + + var adopted = await gateway.FindCategoryAsync(guildId, name, cancellationToken).ConfigureAwait(false); + var categoryId = adopted ?? await gateway.CreateCategoryAsync(guildId, name, cancellationToken).ConfigureAwait(false); + await store.SaveCategoryAsync( + new ProvisionedCategory { GuildId = guildId, RustServerId = serverId, DiscordCategoryId = categoryId }, + cancellationToken).ConfigureAwait(false); + return categoryId; + } + + private async Task> EnsureChannelsAsync(ulong guildId, Guid? serverId, ulong categoryId, string culture, WorkspaceScope scope, CancellationToken cancellationToken) + { + var existing = (await store.GetChannelsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false)) + .ToDictionary(c => c.ChannelKey, StringComparer.Ordinal); + var result = new Dictionary(StringComparer.Ordinal); + var specs = registry.GetChannelSpecs(scope); + + foreach (var spec in specs) + { + var name = localizer.Get(spec.NameKey, culture); + ulong channelId; + + if (existing.TryGetValue(spec.Key, out var rec) && gateway.ChannelExists(guildId, rec.DiscordChannelId)) + { + channelId = rec.DiscordChannelId; + await gateway.ApplyChannelSettingsAsync(guildId, channelId, categoryId, name, spec.Permissions, cancellationToken).ConfigureAwait(false); + } + else + { + var adopted = await gateway.FindChannelAsync(guildId, categoryId, name, cancellationToken).ConfigureAwait(false); + if (adopted is ulong adoptedId) + { + channelId = adoptedId; + await gateway.ApplyChannelSettingsAsync(guildId, channelId, categoryId, name, spec.Permissions, cancellationToken).ConfigureAwait(false); + } + else + { + channelId = await gateway.CreateChannelAsync(guildId, categoryId, name, spec.Permissions, cancellationToken).ConfigureAwait(false); + } + + await store.SaveChannelAsync( + new ProvisionedChannel { GuildId = guildId, RustServerId = serverId, ChannelKey = spec.Key, DiscordChannelId = channelId }, + cancellationToken).ConfigureAwait(false); + } + + result[spec.Key] = channelId; + } + + var registryKeys = specs.Select(s => s.Key).ToHashSet(StringComparer.Ordinal); + if (logger.IsEnabled(LogLevel.Information)) + { + foreach (var orphan in existing.Keys.Where(k => !registryKeys.Contains(k))) + { + logger.LogInformation("Retaining provisioned channel '{Key}' no longer in the registry (guild {GuildId}).", orphan, guildId); + } + } + + return result; + } + + private async Task EnsureMessagesAsync(ulong guildId, Guid? serverId, Dictionary channelIds, string culture, WorkspaceScope scope, CancellationToken cancellationToken) + { + foreach (var spec in registry.GetMessageSpecs(scope)) + { + if (!channelIds.TryGetValue(spec.ChannelKey, out var channelId) || !_renderers.TryGetValue(spec.Key, out var renderer)) + { + continue; + } + + var payload = await renderer.RenderAsync(new MessageRenderContext(guildId, serverId, culture), cancellationToken).ConfigureAwait(false); + var record = await store.GetMessageAsync(guildId, serverId, spec.Key, cancellationToken).ConfigureAwait(false); + + var canEditInPlace = record is not null + && record.DiscordChannelId == channelId + && await gateway.MessageExistsAsync(guildId, channelId, record.DiscordMessageId, cancellationToken).ConfigureAwait(false); + + if (canEditInPlace) + { + await gateway.EditMessageAsync(guildId, channelId, record!.DiscordMessageId, payload, cancellationToken).ConfigureAwait(false); + } + else + { + var messageId = await gateway.PostMessageAsync(guildId, channelId, payload, cancellationToken).ConfigureAwait(false); + await store.SaveMessageAsync( + new ProvisionedMessage { GuildId = guildId, RustServerId = serverId, MessageKey = spec.Key, DiscordChannelId = channelId, DiscordMessageId = messageId }, + cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/src/RustPlusBot.Persistence/Servers/IServerService.cs b/src/RustPlusBot.Persistence/Servers/IServerService.cs index 1df2ea4b..57f77484 100644 --- a/src/RustPlusBot.Persistence/Servers/IServerService.cs +++ b/src/RustPlusBot.Persistence/Servers/IServerService.cs @@ -32,4 +32,11 @@ Task AddAsync(ulong guildId, /// A cancellation token. /// True if a matching server was removed; otherwise false. Task RemoveAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + + /// Gets a server by id within a guild, or null. + /// Owning Discord guild snowflake. + /// The server id. + /// A cancellation token. + /// The server, or null if not found in this guild. + Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); } diff --git a/src/RustPlusBot.Persistence/Servers/ServerService.cs b/src/RustPlusBot.Persistence/Servers/ServerService.cs index 9784bf9a..a20c27b5 100644 --- a/src/RustPlusBot.Persistence/Servers/ServerService.cs +++ b/src/RustPlusBot.Persistence/Servers/ServerService.cs @@ -40,6 +40,10 @@ await context.RustServers .ToListAsync(cancellationToken) .ConfigureAwait(false); + /// + public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) => + context.RustServers.SingleOrDefaultAsync(s => s.GuildId == guildId && s.Id == serverId, cancellationToken); + /// public async Task RemoveAsync( ulong guildId, diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs new file mode 100644 index 00000000..5075d959 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs @@ -0,0 +1,92 @@ +using System.Collections.Concurrent; +using RustPlusBot.Domain.Workspace; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Tests.Fakes; + +/// In-memory for reconciler unit tests. +internal sealed class FakeWorkspaceStore : IWorkspaceStore +{ + private static string Scope(ulong g, Guid? s) => $"{g}:{s?.ToString() ?? "global"}"; + + private readonly ConcurrentDictionary _categories = new(); + + /// Key: scope|channelKey. + private readonly ConcurrentDictionary _channels = new(); + + /// Key: scope|messageKey. + private readonly ConcurrentDictionary _messages = new(); + + private readonly ConcurrentDictionary _cultures = new(); + + public Task GetCategoryAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) => + Task.FromResult(_categories.GetValueOrDefault(Scope(guildId, serverId))); + + public Task SaveCategoryAsync(ProvisionedCategory category, CancellationToken cancellationToken = default) + { + _categories[Scope(category.GuildId, category.RustServerId)] = category; + return Task.CompletedTask; + } + + public Task> GetChannelsAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) + { + var prefix = Scope(guildId, serverId) + "|"; + IReadOnlyList list = _channels + .Where(kv => kv.Key.StartsWith(prefix, StringComparison.Ordinal)) + .Select(kv => kv.Value).ToList(); + return Task.FromResult(list); + } + + public Task SaveChannelAsync(ProvisionedChannel channel, CancellationToken cancellationToken = default) + { + _channels[$"{Scope(channel.GuildId, channel.RustServerId)}|{channel.ChannelKey}"] = channel; + return Task.CompletedTask; + } + + public Task GetMessageAsync(ulong guildId, Guid? serverId, string messageKey, CancellationToken cancellationToken = default) => + Task.FromResult(_messages.GetValueOrDefault($"{Scope(guildId, serverId)}|{messageKey}")); + + public Task SaveMessageAsync(ProvisionedMessage message, CancellationToken cancellationToken = default) + { + _messages[$"{Scope(message.GuildId, message.RustServerId)}|{message.MessageKey}"] = message; + return Task.CompletedTask; + } + + public Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken = default) => + Task.FromResult(_cultures.GetValueOrDefault(guildId, "en")); + + public Task SetCultureAsync(ulong guildId, string culture, CancellationToken cancellationToken = default) + { + _cultures[guildId] = culture; + return Task.CompletedTask; + } + + public Task DeleteScopeAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) + { + _categories.TryRemove(Scope(guildId, serverId), out _); + var prefix = Scope(guildId, serverId) + "|"; + foreach (var key in _channels.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList()) + { + _channels.TryRemove(key, out _); + } + + foreach (var key in _messages.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList()) + { + _messages.TryRemove(key, out _); + } + + return Task.CompletedTask; + } + + public Task> GetAllCategoriesAsync(ulong guildId, CancellationToken cancellationToken = default) + { + IReadOnlyList list = _categories.Values.Where(c => c.GuildId == guildId).ToList(); + return Task.FromResult(list); + } + + public Task> GetProvisionedGuildIdsAsync(CancellationToken cancellationToken = default) + { + IReadOnlyList list = _categories.Values.Select(c => c.GuildId).Distinct().ToList(); + return Task.FromResult(list); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs new file mode 100644 index 00000000..b14f9b39 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Localization; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Features.Workspace.Tests.Fakes; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +/// Builds a WorkspaceReconciler wired with fakes for tests. +internal sealed class ReconcilerHarness +{ + public FakeWorkspaceGateway Gateway { get; } = new(); + public FakeWorkspaceStore Store { get; } = new(); + public IServerService Servers { get; } = Substitute.For(); + + private readonly List _channelProviders = []; + private readonly List _messageProviders = []; + private readonly List _renderers = []; + + public ReconcilerHarness WithChannel(WorkspaceScope scope, string key, string nameKey, int order = 0) + { + _channelProviders.Add(new StubChannelProvider([new ChannelSpec(scope, key, nameKey, ChannelPermissionProfile.ReadOnly, order)])); + return this; + } + + public ReconcilerHarness WithMessage(WorkspaceScope scope, string key, string channelKey, string text) + { + _messageProviders.Add(new StubMessageProvider([new MessageSpec(scope, key, channelKey)])); + _renderers.Add(new StubRenderer(key, text)); + return this; + } + + public WorkspaceReconciler Build() + { + var registry = new WorkspaceRegistry(_channelProviders, _messageProviders); + return new WorkspaceReconciler( + registry, Gateway, Store, _renderers, Servers, + new Localizer(LocalizationCatalog.Default), new ProvisioningLock(), + NullLogger.Instance); + } + + private sealed class StubChannelProvider(IEnumerable specs) : IChannelSpecProvider + { + public IEnumerable GetChannelSpecs() => specs; + } + + private sealed class StubMessageProvider(IEnumerable specs) : IMessageSpecProvider + { + public IEnumerable GetMessageSpecs() => specs; + } + + private sealed class StubRenderer(string key, string text) : IMessageRenderer + { + public string MessageKey { get; } = key; + public ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) => + ValueTask.FromResult(new MessagePayload(text, null, null)); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerProvisionTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerProvisionTests.cs new file mode 100644 index 00000000..23980965 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerProvisionTests.cs @@ -0,0 +1,45 @@ +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class WorkspaceReconcilerProvisionTests +{ + private static ReconcilerHarness GlobalHarness() => new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0) + .WithChannel(WorkspaceScope.Global, "setup", "channel.setup.name", 1) + .WithChannel(WorkspaceScope.Global, "settings", "channel.settings.name", 2) + .WithMessage(WorkspaceScope.Global, "information.main", "information", "hello"); + + [Fact] + public async Task ReconcileGlobal_FreshGuild_CreatesCategoryChannelsAndMessage() + { + var harness = GlobalHarness(); + var sut = harness.Build(); + + var result = await sut.ReconcileGlobalAsync(1); + + Assert.Equal(ReconcileStatus.Provisioned, result.Status); + Assert.Equal(1, harness.Gateway.CreatedCategories); + Assert.Equal(3, harness.Gateway.CreatedChannels); + Assert.Equal(1, harness.Gateway.PostedMessages); + Assert.NotNull(await harness.Store.GetCategoryAsync(1, null)); + Assert.Equal(3, (await harness.Store.GetChannelsAsync(1, null)).Count); + Assert.NotNull(await harness.Store.GetMessageAsync(1, null, "information.main")); + } + + [Fact] + public async Task ReconcileGlobal_MissingBotPermissions_DoesNothing() + { + var harness = GlobalHarness(); + harness.Gateway.MissingPermissions = ["Manage Channels"]; + var sut = harness.Build(); + + var result = await sut.ReconcileGlobalAsync(1); + + Assert.Equal(ReconcileStatus.MissingPermissions, result.Status); + Assert.Equal(["Manage Channels"], result.MissingPermissions); + Assert.Equal(0, harness.Gateway.CreatedCategories); + Assert.Equal(0, harness.Gateway.CreatedChannels); + } +} From dbf27ae262399a7fe7ca12f886527fd43794abe9 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:25:26 +0200 Subject: [PATCH 11/29] =?UTF-8?q?refactor(workspace):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20Skipped=20status=20+=20UpdatedAt=20on=20edit-in-?= =?UTF-8?q?place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reconciler/ReconcileResult.cs | 6 ++++++ .../Reconciler/WorkspaceReconciler.cs | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs b/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs index 869154a4..f36a911a 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs @@ -8,6 +8,9 @@ internal enum ReconcileStatus /// The bot lacks required guild permissions; nothing was changed. MissingPermissions = 1, + + /// Nothing was done because the target no longer exists (e.g. an unknown server). + Skipped = 2, } /// Result of a reconcile, including any missing permissions. @@ -18,6 +21,9 @@ internal sealed record ReconcileResult(ReconcileStatus Status, IReadOnlyListA successful provision result. public static ReconcileResult Provisioned { get; } = new(ReconcileStatus.Provisioned, []); + /// A skipped (no-op) result, e.g. the target no longer exists. + public static ReconcileResult Skipped { get; } = new(ReconcileStatus.Skipped, []); + /// Builds a missing-permissions result. /// The missing permission names. /// A missing-permissions result. diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs index 33d67ed9..603ecb2a 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs @@ -62,7 +62,7 @@ public async Task ReconcileServerAsync(ulong guildId, Guid serv if (server is null) { logger.LogWarning("ReconcileServer skipped: server {ServerId} not found in guild {GuildId}.", serverId, guildId); - return ReconcileResult.Provisioned; + return ReconcileResult.Skipped; } var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); @@ -162,6 +162,9 @@ private async Task EnsureMessagesAsync(ulong guildId, Guid? serverId, Dictionary if (canEditInPlace) { await gateway.EditMessageAsync(guildId, channelId, record!.DiscordMessageId, payload, cancellationToken).ConfigureAwait(false); + await store.SaveMessageAsync( + new ProvisionedMessage { GuildId = guildId, RustServerId = serverId, MessageKey = spec.Key, DiscordChannelId = channelId, DiscordMessageId = record.DiscordMessageId }, + cancellationToken).ConfigureAwait(false); } else { From 6e16b5ad2d1439e73b031bd4bbe2efc536304ad5 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:27:31 +0200 Subject: [PATCH 12/29] test(workspace): reconciler is idempotent on re-run Co-Authored-By: Claude Sonnet 4.6 --- .../WorkspaceReconcilerIdempotencyTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerIdempotencyTests.cs diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerIdempotencyTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerIdempotencyTests.cs new file mode 100644 index 00000000..2ca74b1c --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerIdempotencyTests.cs @@ -0,0 +1,27 @@ +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class WorkspaceReconcilerIdempotencyTests +{ + private static ReconcilerHarness Harness() => new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0) + .WithMessage(WorkspaceScope.Global, "information.main", "information", "hello"); + + [Fact] + public async Task RunTwice_DoesNotDuplicate() + { + var harness = Harness(); + var sut = harness.Build(); + + await sut.ReconcileGlobalAsync(1); + await sut.ReconcileGlobalAsync(1); + + Assert.Equal(1, harness.Gateway.CreatedCategories); + Assert.Equal(1, harness.Gateway.CreatedChannels); + Assert.Equal(1, harness.Gateway.PostedMessages); + Assert.Equal(1, harness.Gateway.EditedMessages); // second run edits the anchored message in place + Assert.Single(harness.Gateway.CategoryIds); + Assert.Single(harness.Gateway.ChannelIds); + } +} From 21eb77d14157d20d33f9db8c298e4a5097330c51 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:27:56 +0200 Subject: [PATCH 13/29] test(workspace): reconciler adopts same-named channel on drift Co-Authored-By: Claude Sonnet 4.6 --- .../WorkspaceReconcilerAdoptTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerAdoptTests.cs diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerAdoptTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerAdoptTests.cs new file mode 100644 index 00000000..61d476fa --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerAdoptTests.cs @@ -0,0 +1,29 @@ +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class WorkspaceReconcilerAdoptTests +{ + [Fact] + public async Task StoredChannelGone_ButSameNamedExists_RebindsNotRecreates() + { + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0); + var sut = harness.Build(); + await sut.ReconcileGlobalAsync(1); + + // Simulate: the channel row still points at an id that no longer exists, but a same-named + // channel exists under the category (e.g. recreated out of band). + var channel = (await harness.Store.GetChannelsAsync(1, null))[0]; + harness.Gateway.ExternallyDeleteChannel(channel.DiscordChannelId); + var category = await harness.Store.GetCategoryAsync(1, null); + await harness.Gateway.CreateChannelAsync(1, category!.DiscordCategoryId, "information", ChannelPermissionProfile.ReadOnly, default); + var createdBefore = harness.Gateway.CreatedChannels; + + await sut.ReconcileGlobalAsync(1); + + Assert.Equal(createdBefore, harness.Gateway.CreatedChannels); // adopted, not created + var rebound = (await harness.Store.GetChannelsAsync(1, null))[0]; + Assert.True(harness.Gateway.ChannelExists(1, rebound.DiscordChannelId)); + } +} From 8e45a1c35c776298a3d132d77f37bf47987b4524 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:28:18 +0200 Subject: [PATCH 14/29] test(workspace): reconciler self-heals a deleted channel Co-Authored-By: Claude Sonnet 4.6 --- .../WorkspaceReconcilerSelfHealTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerSelfHealTests.cs diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerSelfHealTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerSelfHealTests.cs new file mode 100644 index 00000000..a5436a54 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerSelfHealTests.cs @@ -0,0 +1,25 @@ +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class WorkspaceReconcilerSelfHealTests +{ + [Fact] + public async Task DeletedChannel_IsRecreatedOnReconcile() + { + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0) + .WithMessage(WorkspaceScope.Global, "information.main", "information", "hello"); + var sut = harness.Build(); + await sut.ReconcileGlobalAsync(1); + + var channel = (await harness.Store.GetChannelsAsync(1, null))[0]; + harness.Gateway.ExternallyDeleteChannel(channel.DiscordChannelId); + + await sut.ReconcileGlobalAsync(1); + + var healed = (await harness.Store.GetChannelsAsync(1, null))[0]; + Assert.True(harness.Gateway.ChannelExists(1, healed.DiscordChannelId)); + Assert.Equal(2, harness.Gateway.CreatedChannels); // original + heal (no same-named adopt available) + } +} From 015006e3fadee165b1763bc82e76c6a8807c9174 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:29:06 +0200 Subject: [PATCH 15/29] test(workspace): message repost-vs-edit and orphan-spec retention Co-Authored-By: Claude Sonnet 4.6 --- .../Reconciler/ReconcilerHarness.cs | 24 ++++++++++ .../WorkspaceReconcilerMessageTests.cs | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs index b14f9b39..1b1a1baf 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs @@ -60,3 +60,27 @@ public ValueTask RenderAsync(MessageRenderContext context, Cance ValueTask.FromResult(new MessagePayload(text, null, null)); } } + +internal sealed class ReconcilerBuilderReusing(ReconcilerHarness source) +{ + private readonly List _channelProviders = []; + private readonly List _messageProviders = []; + private readonly List _renderers = []; + + public ReconcilerBuilderReusing WithChannel(WorkspaceScope scope, string key, string nameKey, int order = 0) + { + _channelProviders.Add(new ListChannelProvider([new ChannelSpec(scope, key, nameKey, ChannelPermissionProfile.ReadOnly, order)])); + return this; + } + + public WorkspaceReconciler Build() => new( + new WorkspaceRegistry(_channelProviders, _messageProviders), + source.Gateway, source.Store, _renderers, source.Servers, + new Localizer(LocalizationCatalog.Default), new ProvisioningLock(), + NullLogger.Instance); + + private sealed class ListChannelProvider(IEnumerable specs) : IChannelSpecProvider + { + public IEnumerable GetChannelSpecs() => specs; + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs new file mode 100644 index 00000000..18f237f9 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs @@ -0,0 +1,45 @@ +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class WorkspaceReconcilerMessageTests +{ + [Fact] + public async Task DeletedMessage_IsReposted_NotEdited() + { + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0) + .WithMessage(WorkspaceScope.Global, "information.main", "information", "hello"); + var sut = harness.Build(); + await sut.ReconcileGlobalAsync(1); + + var message = await harness.Store.GetMessageAsync(1, null, "information.main"); + harness.Gateway.ExternallyDeleteMessage(message!.DiscordMessageId); + + await sut.ReconcileGlobalAsync(1); + + Assert.Equal(2, harness.Gateway.PostedMessages); // reposted because the anchor was gone + } + + [Fact] + public async Task ChannelKeyRemovedFromRegistry_RetainsExistingRecord() + { + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0) + .WithChannel(WorkspaceScope.Global, "settings", "channel.settings.name", 1); + var sut = harness.Build(); + await sut.ReconcileGlobalAsync(1); + + // Re-run with a registry that no longer contains "settings", reusing the SAME store + gateway + // so the prior "settings" record is still present. + var sut2 = new ReconcilerBuilderReusing(harness) + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0) + .Build(); + + await sut2.ReconcileGlobalAsync(1); + + // The orphaned "settings" channel record is retained, not deleted. + var channels = await harness.Store.GetChannelsAsync(1, null); + Assert.Contains(channels, c => c.ChannelKey == "settings"); + } +} From decf7f1376e42603fec9029802736aca7212a716 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:29:26 +0200 Subject: [PATCH 16/29] test(workspace): concurrent reconciles converge once Co-Authored-By: Claude Sonnet 4.6 --- .../WorkspaceReconcilerConcurrencyTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerConcurrencyTests.cs diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerConcurrencyTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerConcurrencyTests.cs new file mode 100644 index 00000000..306c0356 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerConcurrencyTests.cs @@ -0,0 +1,20 @@ +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class WorkspaceReconcilerConcurrencyTests +{ + [Fact] + public async Task ConcurrentReconciles_CreateOneCategory() + { + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0); + var sut = harness.Build(); + + await Task.WhenAll(sut.ReconcileGlobalAsync(1), sut.ReconcileGlobalAsync(1)); + + Assert.Equal(1, harness.Gateway.CreatedCategories); + Assert.Single(harness.Gateway.CategoryIds); + Assert.Single(harness.Gateway.ChannelIds); + } +} From 377d2863d4c8b6e683ec16807e3bb204a4a2596e Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:31:16 +0200 Subject: [PATCH 17/29] test(workspace): per-server category provisioning --- .../WorkspaceReconcilerServerTests.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerServerTests.cs diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerServerTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerServerTests.cs new file mode 100644 index 00000000..790ba6f8 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerServerTests.cs @@ -0,0 +1,46 @@ +using NSubstitute; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class WorkspaceReconcilerServerTests +{ + [Fact] + public async Task ReconcileServer_CreatesCategoryNamedAfterServer_ScopedToServer() + { + var serverId = Guid.NewGuid(); + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0) + .WithMessage(WorkspaceScope.PerServer, "server.info", "info", "info"); + harness.Servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "Rustopia EU", Ip = "1.1.1.1", Port = 28015 }); + var sut = harness.Build(); + + var result = await sut.ReconcileServerAsync(1, serverId); + + Assert.Equal(ReconcileStatus.Provisioned, result.Status); + var category = await harness.Store.GetCategoryAsync(1, serverId); + Assert.NotNull(category); + Assert.Equal(category!.DiscordCategoryId, await harness.Gateway.FindCategoryAsync(1, "Rustopia EU", default)); + var channels = await harness.Store.GetChannelsAsync(1, serverId); + Assert.Single(channels); + Assert.Equal(serverId, channels[0].RustServerId); + Assert.Empty(await harness.Store.GetChannelsAsync(1, null)); // global scope untouched + } + + [Fact] + public async Task ReconcileServer_UnknownServer_IsNoOp() + { + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0); + harness.Servers.GetAsync(1, Arg.Any(), Arg.Any()).Returns((RustServer?)null); + var sut = harness.Build(); + + var result = await sut.ReconcileServerAsync(1, Guid.NewGuid()); + + Assert.Equal(ReconcileStatus.Skipped, result.Status); + Assert.Equal(0, harness.Gateway.CreatedCategories); + } +} From 64490fc50fe7dfa1300e1d613c375c909e4ceb49 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:36:00 +0200 Subject: [PATCH 18/29] feat(workspace): channel/message keys, renderers, and spec providers Co-Authored-By: Claude Sonnet 4.6 --- .../Messages/InformationMessageRenderer.cs | 32 +++++++++++ .../Messages/ServerInfoMessageRenderer.cs | 41 ++++++++++++++ .../Messages/SettingsMessageRenderer.cs | 37 ++++++++++++ .../Messages/SetupMessageRenderer.cs | 26 +++++++++ .../Specs/GlobalWorkspaceSpecProvider.cs | 23 ++++++++ .../Specs/ServerWorkspaceSpecProvider.cs | 19 +++++++ .../WorkspaceKeys.cs | 33 +++++++++++ .../Messages/RendererTests.cs | 56 +++++++++++++++++++ 8 files changed, 267 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs create mode 100644 src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs create mode 100644 src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs create mode 100644 src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs create mode 100644 src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs create mode 100644 src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs create mode 100644 src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs new file mode 100644 index 00000000..3e4b1724 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs @@ -0,0 +1,32 @@ +using Discord; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Localization; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Workspace.Messages; + +/// Renders the global #information status/help embed. +/// Used for the registered-server count. +/// String resolution. +internal sealed class InformationMessageRenderer(IServerService servers, ILocalizer localizer) : IMessageRenderer +{ + /// + public string MessageKey => WorkspaceMessageKeys.InformationMain; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var count = (await servers.ListAsync(context.GuildId, cancellationToken).ConfigureAwait(false)).Count; + var description = localizer.Get("information.body", context.Culture) + + "\n\n" + + localizer.Get("information.servers", context.Culture, count); + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("information.title", context.Culture)) + .WithDescription(description) + .WithColor(Color.Orange) + .Build(); + return new MessagePayload(null, embed, null); + } +} diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs new file mode 100644 index 00000000..a85455da --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs @@ -0,0 +1,41 @@ +using System.Globalization; +using Discord; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Localization; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Workspace.Messages; + +/// Renders a server's #info identity embed (static in 1a; live status enriched in 1b). +/// Server lookup. +/// String resolution. +internal sealed class ServerInfoMessageRenderer(IServerService servers, ILocalizer localizer) : IMessageRenderer +{ + /// + public string MessageKey => WorkspaceMessageKeys.ServerInfo; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + if (context.ServerId is not Guid serverId) + { + return new MessagePayload(null, null, null); + } + + var server = await servers.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (server is null) + { + return new MessagePayload(null, null, null); + } + + var port = server.Port.ToString(CultureInfo.InvariantCulture); + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("server.info.title", context.Culture, server.Name)) + .WithDescription(localizer.Get("server.info.endpoint", context.Culture, server.Ip, port)) + .WithColor(Color.Green) + .Build(); + return new MessagePayload(null, embed, null); + } +} diff --git a/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs new file mode 100644 index 00000000..38bf7c50 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs @@ -0,0 +1,37 @@ +using Discord; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Localization; +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Messages; + +/// Renders the global #settings message with the language selector. +/// String resolution. +internal sealed class SettingsMessageRenderer(ILocalizer localizer) : IMessageRenderer +{ + /// Custom id of the language select menu, handled by the settings component module. + public const string LanguageSelectId = "workspace:settings:culture"; + + /// + public string MessageKey => WorkspaceMessageKeys.SettingsMain; + + /// + public ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("settings.title", context.Culture)) + .WithDescription(localizer.Get("settings.body", context.Culture)) + .WithColor(Color.DarkGrey) + .Build(); + + var menu = new SelectMenuBuilder() + .WithCustomId(LanguageSelectId) + .WithPlaceholder(localizer.Get("settings.language.label", context.Culture)) + .AddOption("English", "en") + .AddOption("Francais", "fr"); + var components = new ComponentBuilder().WithSelectMenu(menu).Build(); + + return ValueTask.FromResult(new MessagePayload(null, embed, components)); + } +} diff --git a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs new file mode 100644 index 00000000..7df8ad40 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs @@ -0,0 +1,26 @@ +using Discord; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Localization; +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Messages; + +/// Renders the global #setup instructions (the interactive button arrives in 1b). +/// String resolution. +internal sealed class SetupMessageRenderer(ILocalizer localizer) : IMessageRenderer +{ + /// + public string MessageKey => WorkspaceMessageKeys.SetupMain; + + /// + public ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("setup.title", context.Culture)) + .WithDescription(localizer.Get("setup.body", context.Culture)) + .WithColor(Color.Blue) + .Build(); + return ValueTask.FromResult(new MessagePayload(null, embed, null)); + } +} diff --git a/src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs new file mode 100644 index 00000000..cc123ebc --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs @@ -0,0 +1,23 @@ +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Specs; + +/// Contributes the global category's channels and messages. +internal sealed class GlobalWorkspaceSpecProvider : IChannelSpecProvider, IMessageSpecProvider +{ + /// + public IEnumerable GetChannelSpecs() => + [ + new(WorkspaceScope.Global, WorkspaceChannelKeys.Information, "channel.information.name", ChannelPermissionProfile.ReadOnly, 0), + new(WorkspaceScope.Global, WorkspaceChannelKeys.Setup, "channel.setup.name", ChannelPermissionProfile.ReadOnly, 1), + new(WorkspaceScope.Global, WorkspaceChannelKeys.Settings, "channel.settings.name", ChannelPermissionProfile.ReadOnly, 2), + ]; + + /// + public IEnumerable GetMessageSpecs() => + [ + new(WorkspaceScope.Global, WorkspaceMessageKeys.InformationMain, WorkspaceChannelKeys.Information), + new(WorkspaceScope.Global, WorkspaceMessageKeys.SetupMain, WorkspaceChannelKeys.Setup), + new(WorkspaceScope.Global, WorkspaceMessageKeys.SettingsMain, WorkspaceChannelKeys.Settings), + ]; +} diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs new file mode 100644 index 00000000..fb22189b --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs @@ -0,0 +1,19 @@ +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Specs; + +/// Contributes the per-server category's channels and messages (just #info in 1a). +internal sealed class ServerWorkspaceSpecProvider : IChannelSpecProvider, IMessageSpecProvider +{ + /// + public IEnumerable GetChannelSpecs() => + [ + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerInfo, "channel.info.name", ChannelPermissionProfile.ReadOnly, 0), + ]; + + /// + public IEnumerable GetMessageSpecs() => + [ + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerInfo, WorkspaceChannelKeys.ServerInfo), + ]; +} diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs new file mode 100644 index 00000000..dc6f3c3f --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs @@ -0,0 +1,33 @@ +namespace RustPlusBot.Features.Workspace; + +/// Stable channel keys persisted as ProvisionedChannel.ChannelKey. +internal static class WorkspaceChannelKeys +{ + /// Key for the #information channel. + public const string Information = "information"; + + /// Key for the #setup channel. + public const string Setup = "setup"; + + /// Key for the #settings channel. + public const string Settings = "settings"; + + /// Key for the per-server #info channel. + public const string ServerInfo = "info"; +} + +/// Stable message keys persisted as ProvisionedMessage.MessageKey. +internal static class WorkspaceMessageKeys +{ + /// Key for the main information message. + public const string InformationMain = "information.main"; + + /// Key for the main setup message. + public const string SetupMain = "setup.main"; + + /// Key for the main settings message. + public const string SettingsMain = "settings.main"; + + /// Key for the per-server info message. + public const string ServerInfo = "server.info"; +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs new file mode 100644 index 00000000..5f7ebc0b --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -0,0 +1,56 @@ +using Discord; +using NSubstitute; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Workspace.Localization; +using RustPlusBot.Features.Workspace.Messages; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Workspace.Tests.Messages; + +public sealed class RendererTests +{ + private static readonly Localizer Loc = new(LocalizationCatalog.Default); + private static readonly MessageRenderContext Global = new(1, null, "en"); + + [Fact] + public async Task Information_ShowsServerCount() + { + var servers = Substitute.For(); + servers.ListAsync(1, Arg.Any()) + .Returns(new List { new() { Name = "A" }, new() { Name = "B" }, new() { Name = "C" } }); + var renderer = new InformationMessageRenderer(servers, Loc); + + var payload = await renderer.RenderAsync(Global, default); + + Assert.NotNull(payload.Embed); + Assert.Contains("3", payload.Embed!.Description, StringComparison.Ordinal); + } + + [Fact] + public async Task Settings_HasLanguageSelectMenu() + { + var renderer = new SettingsMessageRenderer(Loc); + + var payload = await renderer.RenderAsync(Global, default); + + Assert.NotNull(payload.Components); + var selects = payload.Components!.Components.OfType().SelectMany(r => r.Components).OfType(); + Assert.Contains(selects, s => s.CustomId == "workspace:settings:culture"); + } + + [Fact] + public async Task ServerInfo_TitleIsServerName_AndEndpointShown() + { + var serverId = Guid.NewGuid(); + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "Rustopia EU", Ip = "1.2.3.4", Port = 28015 }); + var renderer = new ServerInfoMessageRenderer(servers, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.Equal("Rustopia EU", payload.Embed!.Title); + Assert.Contains("1.2.3.4", payload.Embed.Description, StringComparison.Ordinal); + } +} From f973511f607f9a1f8dff489772788e02710a9b42 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:38:27 +0200 Subject: [PATCH 19/29] feat(workspace): teardown service (RemoveServer / ResetGuild) --- .../Teardown/IWorkspaceTeardownService.cs | 18 +++++++ .../Teardown/WorkspaceTeardownService.cs | 49 +++++++++++++++++++ .../Teardown/WorkspaceTeardownServiceTests.cs | 47 ++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Teardown/IWorkspaceTeardownService.cs create mode 100644 src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Teardown/WorkspaceTeardownServiceTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Teardown/IWorkspaceTeardownService.cs b/src/RustPlusBot.Features.Workspace/Teardown/IWorkspaceTeardownService.cs new file mode 100644 index 00000000..3fd3e234 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Teardown/IWorkspaceTeardownService.cs @@ -0,0 +1,18 @@ +namespace RustPlusBot.Features.Workspace.Teardown; + +/// Removes provisioned Discord resources and their records. +internal interface IWorkspaceTeardownService +{ + /// Deletes one server's category, channels, and records. + /// The guild. + /// The server to remove. + /// A cancellation token. + /// A task. + Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + + /// Deletes the entire workspace for a guild (all scopes) and clears all records. + /// The guild. + /// A cancellation token. + /// A task. + Task ResetGuildAsync(ulong guildId, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs new file mode 100644 index 00000000..25e8951c --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs @@ -0,0 +1,49 @@ +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Teardown; + +/// Deletes provisioned resources under the per-guild lock, then clears the records. +/// Discord operations. +/// Provisioning persistence. +/// Per-guild serialization (shared with the reconciler). +internal sealed class WorkspaceTeardownService( + IWorkspaceGateway gateway, + IWorkspaceStore store, + IProvisioningLock provisioningLock) : IWorkspaceTeardownService +{ + /// + public async Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) + { + using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); + await DeleteScopeAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ResetGuildAsync(ulong guildId, CancellationToken cancellationToken = default) + { + using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); + var categories = await store.GetAllCategoriesAsync(guildId, cancellationToken).ConfigureAwait(false); + foreach (var category in categories) + { + await DeleteScopeAsync(guildId, category.RustServerId, cancellationToken).ConfigureAwait(false); + } + } + + private async Task DeleteScopeAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken) + { + foreach (var channel in await store.GetChannelsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false)) + { + await gateway.DeleteChannelAsync(guildId, channel.DiscordChannelId, cancellationToken).ConfigureAwait(false); + } + + var category = await store.GetCategoryAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (category is not null) + { + await gateway.DeleteCategoryAsync(guildId, category.DiscordCategoryId, cancellationToken).ConfigureAwait(false); + } + + await store.DeleteScopeAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/WorkspaceTeardownServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/WorkspaceTeardownServiceTests.cs new file mode 100644 index 00000000..37bc33b9 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/WorkspaceTeardownServiceTests.cs @@ -0,0 +1,47 @@ +using NSubstitute; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Features.Workspace.Teardown; +using RustPlusBot.Features.Workspace.Tests.Reconciler; + +namespace RustPlusBot.Features.Workspace.Tests.Teardown; + +public sealed class WorkspaceTeardownServiceTests +{ + [Fact] + public async Task ResetGuild_DeletesChannelsCategoriesAndRecords() + { + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0); + await harness.Build().ReconcileGlobalAsync(1); + + var teardown = new WorkspaceTeardownService(harness.Gateway, harness.Store, new ProvisioningLock()); + await teardown.ResetGuildAsync(1); + + Assert.Empty(harness.Gateway.ChannelIds); + Assert.Empty(harness.Gateway.CategoryIds); + Assert.Null(await harness.Store.GetCategoryAsync(1, null)); + Assert.Empty(await harness.Store.GetChannelsAsync(1, null)); + } + + [Fact] + public async Task RemoveServer_DeletesOnlyThatServerScope() + { + var serverId = Guid.NewGuid(); + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0) + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0); + harness.Servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.1.1.1", Port = 1 }); + var reconciler = harness.Build(); + await reconciler.ReconcileGlobalAsync(1); + await reconciler.ReconcileServerAsync(1, serverId); + + var teardown = new WorkspaceTeardownService(harness.Gateway, harness.Store, new ProvisioningLock()); + await teardown.RemoveServerAsync(1, serverId); + + Assert.Null(await harness.Store.GetCategoryAsync(1, serverId)); + Assert.NotNull(await harness.Store.GetCategoryAsync(1, null)); // global retained + } +} From bbb3d9f4c29895956e454850842e70e2367cbadc Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:40:14 +0200 Subject: [PATCH 20/29] feat: ServerRegisteredEvent, WorkspaceOptions, and interaction-module assembly seam Co-Authored-By: Claude Sonnet 4.6 --- .../Events/ServerRegisteredEvent.cs | 6 ++++++ src/RustPlusBot.Discord/DiscordBotService.cs | 6 ++++++ src/RustPlusBot.Discord/InteractionModuleAssembly.cs | 7 +++++++ src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs | 8 ++++++++ 4 files changed, 27 insertions(+) create mode 100644 src/RustPlusBot.Abstractions/Events/ServerRegisteredEvent.cs create mode 100644 src/RustPlusBot.Discord/InteractionModuleAssembly.cs create mode 100644 src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs diff --git a/src/RustPlusBot.Abstractions/Events/ServerRegisteredEvent.cs b/src/RustPlusBot.Abstractions/Events/ServerRegisteredEvent.cs new file mode 100644 index 00000000..203ed35e --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/ServerRegisteredEvent.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Abstractions.Events; + +/// Published when a Rust server is registered to a guild (stub trigger in 1a; FCM pairing in 1b). +/// The owning guild snowflake. +/// The registered server's id. +public sealed record ServerRegisteredEvent(ulong GuildId, Guid ServerId); diff --git a/src/RustPlusBot.Discord/DiscordBotService.cs b/src/RustPlusBot.Discord/DiscordBotService.cs index e0c26306..002e77d9 100644 --- a/src/RustPlusBot.Discord/DiscordBotService.cs +++ b/src/RustPlusBot.Discord/DiscordBotService.cs @@ -16,6 +16,7 @@ namespace RustPlusBot.Discord; /// The Discord socket client. /// The interaction service that dispatches slash commands. /// The root service provider used to construct interaction modules. +/// Additional assemblies to scan for interaction modules contributed by feature projects. /// The Discord options carrying the bot token. /// The logger. [SuppressMessage("Performance", "CA1873:Avoid potentially expensive logging", @@ -25,6 +26,7 @@ public sealed class DiscordBotService( DiscordSocketClient client, InteractionService interactions, IServiceProvider services, + IEnumerable moduleAssemblies, IOptions options, ILogger logger) : IHostedService { @@ -41,6 +43,10 @@ public async Task StartAsync(CancellationToken cancellationToken) client.JoinedGuild += OnJoinedGuildAsync; await interactions.AddModulesAsync(Assembly.GetExecutingAssembly(), services).ConfigureAwait(false); + foreach (var moduleAssembly in moduleAssemblies) + { + await interactions.AddModulesAsync(moduleAssembly.Assembly, services).ConfigureAwait(false); + } await client.LoginAsync(TokenType.Bot, _options.Token).ConfigureAwait(false); await client.StartAsync().ConfigureAwait(false); } diff --git a/src/RustPlusBot.Discord/InteractionModuleAssembly.cs b/src/RustPlusBot.Discord/InteractionModuleAssembly.cs new file mode 100644 index 00000000..728f138c --- /dev/null +++ b/src/RustPlusBot.Discord/InteractionModuleAssembly.cs @@ -0,0 +1,7 @@ +using System.Reflection; + +namespace RustPlusBot.Discord; + +/// Registers an assembly whose Discord interaction modules should be loaded at startup. +/// The assembly to scan for interaction modules. +public sealed record InteractionModuleAssembly(Assembly Assembly); diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs new file mode 100644 index 00000000..9b896750 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs @@ -0,0 +1,8 @@ +namespace RustPlusBot.Features.Workspace; + +/// Workspace feature configuration, bound from the "Workspace" config section. +public sealed class WorkspaceOptions +{ + /// Enables dangerous developer commands (/workspace reset, /workspace simulate-server). + public bool EnableDangerCommands { get; set; } +} From 390761600a22242d18aa34bf4b3618455df0a8fc Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:44:08 +0200 Subject: [PATCH 21/29] feat(workspace): Discord.Net implementation of IWorkspaceGateway Co-Authored-By: Claude Sonnet 4.6 --- .../Gateway/DiscordWorkspaceGateway.cs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs diff --git a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs new file mode 100644 index 00000000..7a67ba83 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs @@ -0,0 +1,164 @@ +using Discord; +using Discord.WebSocket; +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Gateway; + +/// Discord.Net-backed . +/// The socket client (cache for existence checks; REST for mutations). +internal sealed class DiscordWorkspaceGateway(DiscordSocketClient client) : IWorkspaceGateway +{ + /// + public bool CategoryExists(ulong guildId, ulong categoryId) => + client.GetGuild(guildId)?.GetCategoryChannel(categoryId) is not null; + + /// + public Task FindCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken) + { + var match = client.GetGuild(guildId)?.CategoryChannels + .FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(match?.Id); + } + + /// + public async Task CreateCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken) + { + var guild = GetGuild(guildId); + var category = await guild.CreateCategoryChannelAsync(name).ConfigureAwait(false); + return category.Id; + } + + /// + public bool ChannelExists(ulong guildId, ulong channelId) => + client.GetGuild(guildId)?.GetTextChannel(channelId) is not null; + + /// + public Task FindChannelAsync(ulong guildId, ulong categoryId, string name, CancellationToken cancellationToken) + { + var match = client.GetGuild(guildId)?.TextChannels + .FirstOrDefault(c => c.CategoryId == categoryId && string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(match?.Id); + } + + /// + public async Task CreateChannelAsync(ulong guildId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken) + { + var guild = GetGuild(guildId); + var channel = await guild.CreateTextChannelAsync(name, props => props.CategoryId = categoryId).ConfigureAwait(false); + await ApplyOverwritesAsync(guild, channel, profile).ConfigureAwait(false); + return channel.Id; + } + + /// + public async Task ApplyChannelSettingsAsync(ulong guildId, ulong channelId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken) + { + var guild = client.GetGuild(guildId); + var channel = guild?.GetTextChannel(channelId); + if (guild is null || channel is null) + { + return; + } + + if (channel.CategoryId != categoryId || !string.Equals(channel.Name, name, StringComparison.Ordinal)) + { + await channel.ModifyAsync(props => + { + props.CategoryId = categoryId; + props.Name = name; + }).ConfigureAwait(false); + } + + await ApplyOverwritesAsync(guild, channel, profile).ConfigureAwait(false); + } + + /// + public async Task MessageExistsAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken) + { + var channel = client.GetGuild(guildId)?.GetTextChannel(channelId); + if (channel is null) + { + return false; + } + + var message = await channel.GetMessageAsync(messageId).ConfigureAwait(false); + return message is not null; + } + + /// + public async Task PostMessageAsync(ulong guildId, ulong channelId, MessagePayload payload, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(payload); + var channel = client.GetGuild(guildId)?.GetTextChannel(channelId) + ?? throw new InvalidOperationException($"Channel {channelId} not found in guild {guildId}."); + var message = await channel.SendMessageAsync(text: payload.Text, embed: payload.Embed, components: payload.Components).ConfigureAwait(false); + return message.Id; + } + + /// + public async Task EditMessageAsync(ulong guildId, ulong channelId, ulong messageId, MessagePayload payload, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(payload); + var channel = client.GetGuild(guildId)?.GetTextChannel(channelId); + if (channel is null) + { + return; + } + + await channel.ModifyMessageAsync(messageId, props => + { + props.Content = payload.Text; + props.Embed = payload.Embed; + props.Components = payload.Components; + }).ConfigureAwait(false); + } + + /// + public async Task DeleteChannelAsync(ulong guildId, ulong channelId, CancellationToken cancellationToken) + { + var channel = client.GetGuild(guildId)?.GetChannel(channelId); + if (channel is not null) + { + await channel.DeleteAsync().ConfigureAwait(false); + } + } + + /// + public async Task DeleteCategoryAsync(ulong guildId, ulong categoryId, CancellationToken cancellationToken) + { + var category = client.GetGuild(guildId)?.GetCategoryChannel(categoryId); + if (category is not null) + { + await category.DeleteAsync().ConfigureAwait(false); + } + } + + /// + public IReadOnlyList GetMissingBotPermissions(ulong guildId) + { + var guild = client.GetGuild(guildId); + if (guild is null) + { + return ["Guild not available to the bot"]; + } + + var permissions = guild.CurrentUser.GuildPermissions; + var missing = new List(); + if (!permissions.ManageChannels) { missing.Add("Manage Channels"); } + if (!permissions.ManageRoles) { missing.Add("Manage Roles"); } + if (!permissions.SendMessages) { missing.Add("Send Messages"); } + if (!permissions.EmbedLinks) { missing.Add("Embed Links"); } + if (!permissions.ManageMessages) { missing.Add("Manage Messages"); } + if (!permissions.ViewChannel) { missing.Add("View Channels"); } + return missing; + } + + private SocketGuild GetGuild(ulong guildId) => + client.GetGuild(guildId) ?? throw new InvalidOperationException($"Guild {guildId} not available to the bot."); + + private static Task ApplyOverwritesAsync(SocketGuild guild, ITextChannel channel, ChannelPermissionProfile profile) + { + var send = profile == ChannelPermissionProfile.Interactive ? PermValue.Allow : PermValue.Deny; + var overwrite = OverwritePermissions.InheritAll.Modify(viewChannel: PermValue.Allow, sendMessages: send); + return channel.AddPermissionOverwriteAsync(guild.EveryoneRole, overwrite); + } +} From 0ee077321ebb691614b2aa5307811453ce441dcb Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:47:38 +0200 Subject: [PATCH 22/29] feat(workspace): AddWorkspace DI registration + composition test Introduces WorkspaceServiceCollectionExtensions.AddWorkspace() wiring all workspace singletons and scoped services, validated by a ValidateScopes=true composition test that proves the full DI graph resolves without captive-dependency violations. Co-Authored-By: Claude Sonnet 4.6 --- .../WorkspaceServiceCollectionExtensions.cs | 58 +++++++++++++++++++ .../WorkspaceRegistrationTests.cs | 31 ++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs new file mode 100644 index 00000000..bab3aa99 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Discord; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Localization; +using RustPlusBot.Features.Workspace.Messages; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Features.Workspace.Specs; +using RustPlusBot.Features.Workspace.Teardown; + +namespace RustPlusBot.Features.Workspace; + +/// DI registration for the workspace provisioning feature. +public static class WorkspaceServiceCollectionExtensions +{ + /// Registers the registry, gateway, reconciler, renderers, teardown, and module seam. + /// The service collection to add to. + /// The same service collection, for chaining. + public static IServiceCollection AddWorkspace(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // Spec registry (stateless singletons). + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Localization. + services.AddSingleton(LocalizationCatalog.Default); + services.AddSingleton(); + + // Gateway + per-guild lock. + services.AddSingleton(); + services.AddSingleton(); + + // Renderers (scoped: some query the DbContext via IServerService). + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Reconciler + teardown (scoped). + services.AddScoped(); + services.AddScoped(); + + // Options (Host binds the "Workspace" section; default = danger commands off). + services.AddOptions(); + + // Contribute this assembly's interaction modules to the Discord layer. + services.AddSingleton(new InteractionModuleAssembly(typeof(WorkspaceServiceCollectionExtensions).Assembly)); + + // NOTE: WorkspaceHostedService (Hosting namespace) is registered via AddHostedService in Task 24. + + return services; + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs new file mode 100644 index 00000000..5b218810 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs @@ -0,0 +1,31 @@ +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Workspace; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Teardown; +using RustPlusBot.Persistence; + +namespace RustPlusBot.Features.Workspace.Tests; + +public sealed class WorkspaceRegistrationTests +{ + [Fact] + public void ReconcilerAndTeardown_ResolveFromScope() + { + var services = new ServiceCollection(); + services.AddSingleton(new DiscordSocketClient()); + services.AddSingleton(); + services.AddSingleton(); + services.AddLogging(); + services.AddBotPersistence("DataSource=:memory:"); + services.AddWorkspace(); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + using var scope = provider.CreateScope(); + + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + } +} From b619b9cb38d325c53f8fe156998f9b95bccb5a92 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:49:44 +0200 Subject: [PATCH 23/29] feat(workspace): /setup command module Co-Authored-By: Claude Sonnet 4.6 --- .../Modules/SetupModule.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Modules/SetupModule.cs diff --git a/src/RustPlusBot.Features.Workspace/Modules/SetupModule.cs b/src/RustPlusBot.Features.Workspace/Modules/SetupModule.cs new file mode 100644 index 00000000..88246248 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Modules/SetupModule.cs @@ -0,0 +1,52 @@ +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Workspace.Modules; + +/// Provisions the bot's Discord workspace for the current guild. +/// Creates a short-lived DI scope per interaction. +public sealed class SetupModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + /// Reconciles the global workspace and every known server's workspace. + [SlashCommand("setup", "Provision the bot's channels for this server")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task SetupAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + try + { + var reconciler = scope.ServiceProvider.GetRequiredService(); + var result = await reconciler.ReconcileGlobalAsync(Context.Guild.Id).ConfigureAwait(false); + if (result.Status == ReconcileStatus.MissingPermissions) + { + await FollowupAsync( + $"I'm missing required permissions: {string.Join(", ", result.MissingPermissions)}.", + ephemeral: true).ConfigureAwait(false); + return; + } + + var servers = scope.ServiceProvider.GetRequiredService(); + foreach (var server in await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false)) + { + await reconciler.ReconcileServerAsync(Context.Guild.Id, server.Id).ConfigureAwait(false); + } + + await FollowupAsync("Workspace is ready.", ephemeral: true).ConfigureAwait(false); + } + finally + { + await scope.DisposeAsync().ConfigureAwait(false); + } + } +} From ae7b74dcd7059f9593af959ef26c8396ed46c879 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:54:03 +0200 Subject: [PATCH 24/29] feat(workspace): settings language selector + /workspace reset & simulate-server --- .../Modules/SettingsComponentModule.cs | 46 ++++++++ .../Modules/WorkspaceAdminModule.cs | 109 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs create mode 100644 src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs diff --git a/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs b/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs new file mode 100644 index 00000000..078a120c --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs @@ -0,0 +1,46 @@ +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Workspace.Messages; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Modules; + +/// Handles the language select menu in the #settings channel. +/// Creates a short-lived DI scope per interaction. +public sealed class SettingsComponentModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + /// Persists the chosen culture and re-renders the workspace in the new language. + /// The selected culture codes (expects exactly one). + [ComponentInteraction(SettingsMessageRenderer.LanguageSelectId)] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task SetCultureAsync(string[] selectedValues) + { + ArgumentNullException.ThrowIfNull(selectedValues); + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + var culture = selectedValues.Length > 0 ? selectedValues[0] : "en"; + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + try + { + var store = scope.ServiceProvider.GetRequiredService(); + await store.SetCultureAsync(Context.Guild.Id, culture).ConfigureAwait(false); + + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.ReconcileGlobalAsync(Context.Guild.Id).ConfigureAwait(false); + + await FollowupAsync($"Language set to `{culture}`.", ephemeral: true).ConfigureAwait(false); + } + finally + { + await scope.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs b/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs new file mode 100644 index 00000000..b598e214 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs @@ -0,0 +1,109 @@ +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Teardown; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Workspace.Modules; + +/// Administrative and developer commands for the workspace. +/// Creates a short-lived DI scope per interaction. +/// Workspace options (gates the dangerous commands). +/// Used to publish the stub . +[Group("workspace", "Workspace administration")] +[RequireUserPermission(GuildPermission.ManageGuild)] +public sealed class WorkspaceAdminModule( + IServiceScopeFactory scopeFactory, + IOptions options, + IEventBus eventBus) : InteractionModuleBase +{ + /// Custom id for the reset confirmation button. + public const string ConfirmResetId = "workspace:reset:confirm"; + + /// Prompts to delete the entire provisioned workspace (dev-gated). + [SlashCommand("reset", "Delete ALL provisioned channels and records for this server (dangerous)")] + public async Task ResetAsync() + { + if (!await EnsureEnabledAsync().ConfigureAwait(false)) + { + return; + } + + var components = new ComponentBuilder() + .WithButton("Confirm reset", ConfirmResetId, ButtonStyle.Danger) + .Build(); + await RespondAsync( + "This deletes every channel and category the bot provisioned here. Confirm?", + ephemeral: true, components: components).ConfigureAwait(false); + } + + /// Executes the workspace reset after confirmation. + [ComponentInteraction(ConfirmResetId)] + public async Task ConfirmResetAsync() + { + if (!await EnsureEnabledAsync().ConfigureAwait(false)) + { + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + try + { + var teardown = scope.ServiceProvider.GetRequiredService(); + await teardown.ResetGuildAsync(Context.Guild.Id).ConfigureAwait(false); + await FollowupAsync("Workspace reset.", ephemeral: true).ConfigureAwait(false); + } + finally + { + await scope.DisposeAsync().ConfigureAwait(false); + } + } + + /// Dev: registers a fake server and publishes a ServerRegisteredEvent to test provisioning. + /// Server display name. + /// Server host or ip. + /// Rust+ app port. + [SlashCommand("simulate-server", "Dev: register a fake server to test provisioning")] + public async Task SimulateServerAsync(string name, string ip, int port) + { + if (!await EnsureEnabledAsync().ConfigureAwait(false)) + { + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + try + { + var servers = scope.ServiceProvider.GetRequiredService(); + var server = await servers.AddAsync(Context.Guild.Id, Context.User.Id, name, ip, port).ConfigureAwait(false); + await eventBus.PublishAsync(new ServerRegisteredEvent(Context.Guild.Id, server.Id)).ConfigureAwait(false); + await FollowupAsync($"Registered **{name}** and published ServerRegisteredEvent.", ephemeral: true).ConfigureAwait(false); + } + finally + { + await scope.DisposeAsync().ConfigureAwait(false); + } + } + + private async Task EnsureEnabledAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return false; + } + + if (!options.Value.EnableDangerCommands) + { + await RespondAsync("Developer commands are disabled.", ephemeral: true).ConfigureAwait(false); + return false; + } + + return true; + } +} From 2e04a803a885e87509b1d601d7c7749f6731d7dd Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 22:59:11 +0200 Subject: [PATCH 25/29] feat(workspace): HealGuild self-heal + hosted service (startup, ChannelDestroyed, ServerRegistered) --- .../Hosting/WorkspaceHostedService.cs | 124 ++++++++++++++++++ .../Reconciler/IWorkspaceReconciler.cs | 6 + .../Reconciler/WorkspaceReconciler.cs | 46 ++++++- .../WorkspaceServiceCollectionExtensions.cs | 2 +- .../WorkspaceReconcilerHealTests.cs | 42 ++++++ 5 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs new file mode 100644 index 00000000..ffeab42a --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs @@ -0,0 +1,124 @@ +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Hosting; + +/// Runs startup reconcile, self-heals on channel deletion, and reacts to server registration. +/// The socket client (for Ready and ChannelDestroyed). +/// The in-process event bus. +/// Creates scopes for the scoped reconciler/store. +/// The logger. +internal sealed class WorkspaceHostedService( + DiscordSocketClient client, + IEventBus eventBus, + IServiceScopeFactory scopeFactory, + ILogger logger) : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private Task? _eventLoop; + private bool _startupDone; + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + client.Ready += OnReadyAsync; + client.ChannelDestroyed += OnChannelDestroyedAsync; + _eventLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + client.Ready -= OnReadyAsync; + client.ChannelDestroyed -= OnChannelDestroyedAsync; + await _cts.CancelAsync().ConfigureAwait(false); + if (_eventLoop is not null) + { + try + { +#pragma warning disable VSTHRD003 // Avoid awaiting or returning a Task representing work that was not started within your context + await _eventLoop.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + } + + /// + public void Dispose() => _cts.Dispose(); + + private async Task OnReadyAsync() + { + if (_startupDone) + { + return; + } + + _startupDone = true; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + var reconciler = scope.ServiceProvider.GetRequiredService(); + foreach (var guildId in await store.GetProvisionedGuildIdsAsync().ConfigureAwait(false)) + { + await reconciler.HealGuildAsync(guildId).ConfigureAwait(false); + } + } + } + + private async Task OnChannelDestroyedAsync(SocketChannel channel) + { + if (channel is not SocketGuildChannel guildChannel) + { + return; + } + + try + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.HealGuildAsync(guildChannel.Guild.Id).ConfigureAwait(false); + } + } + catch (Exception ex) // Broad catch is intentional: a faulting self-heal must not crash the host. + { + logger.LogError(ex, "Self-heal failed for guild {GuildId}.", guildChannel.Guild.Id); + } + } + + private async Task ConsumeServerRegisteredAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var registered in eventBus.SubscribeAsync(cancellationToken).ConfigureAwait(false)) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.ReconcileServerAsync(registered.GuildId, registered.ServerId, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // Shutting down. + } + catch (Exception ex) // Broad catch is intentional: a faulting consumer must not crash the host. + { + logger.LogError(ex, "ServerRegistered consumer faulted."); + } + } +} diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs index 76bd63c3..a84aed0b 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs @@ -15,4 +15,10 @@ internal interface IWorkspaceReconciler /// A cancellation token. /// The reconcile result. Task ReconcileServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + + /// Converges an already-provisioned guild (global + all servers). No-op if not provisioned. + /// The guild to heal. + /// A cancellation token. + /// A task. + Task HealGuildAsync(ulong guildId, CancellationToken cancellationToken = default); } diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs index 603ecb2a..2a193bc5 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs @@ -34,16 +34,13 @@ internal sealed class WorkspaceReconciler( public async Task ReconcileGlobalAsync(ulong guildId, CancellationToken cancellationToken = default) { using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); - var missing = gateway.GetMissingBotPermissions(guildId); if (missing.Count > 0) { return ReconcileResult.Missing(missing); } - var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); - var categoryName = localizer.Get("category.global.name", culture); - await ReconcileScopeAsync(guildId, null, categoryName, culture, WorkspaceScope.Global, cancellationToken).ConfigureAwait(false); + await ReconcileGlobalCoreAsync(guildId, cancellationToken).ConfigureAwait(false); return ReconcileResult.Provisioned; } @@ -51,23 +48,58 @@ public async Task ReconcileGlobalAsync(ulong guildId, Cancellat public async Task ReconcileServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) { using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); - var missing = gateway.GetMissingBotPermissions(guildId); if (missing.Count > 0) { return ReconcileResult.Missing(missing); } + var provisioned = await ReconcileServerCoreAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return provisioned ? ReconcileResult.Provisioned : ReconcileResult.Skipped; + } + + /// + public async Task HealGuildAsync(ulong guildId, CancellationToken cancellationToken = default) + { + using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); + + // Only heal an already-provisioned workspace; never resurrect one cleared by reset. + if (await store.GetCategoryAsync(guildId, null, cancellationToken).ConfigureAwait(false) is null) + { + return; + } + + if (gateway.GetMissingBotPermissions(guildId).Count > 0) + { + return; + } + + await ReconcileGlobalCoreAsync(guildId, cancellationToken).ConfigureAwait(false); + foreach (var server in await servers.ListAsync(guildId, cancellationToken).ConfigureAwait(false)) + { + await ReconcileServerCoreAsync(guildId, server.Id, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ReconcileGlobalCoreAsync(ulong guildId, CancellationToken cancellationToken) + { + var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var categoryName = localizer.Get("category.global.name", culture); + await ReconcileScopeAsync(guildId, null, categoryName, culture, WorkspaceScope.Global, cancellationToken).ConfigureAwait(false); + } + + private async Task ReconcileServerCoreAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { var server = await servers.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); if (server is null) { logger.LogWarning("ReconcileServer skipped: server {ServerId} not found in guild {GuildId}.", serverId, guildId); - return ReconcileResult.Skipped; + return false; } var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); await ReconcileScopeAsync(guildId, serverId, server.Name, culture, WorkspaceScope.PerServer, cancellationToken).ConfigureAwait(false); - return ReconcileResult.Provisioned; + return true; } private async Task ReconcileScopeAsync(ulong guildId, Guid? serverId, string categoryName, string culture, WorkspaceScope scope, CancellationToken cancellationToken) diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index bab3aa99..ff060634 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -51,7 +51,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) // Contribute this assembly's interaction modules to the Discord layer. services.AddSingleton(new InteractionModuleAssembly(typeof(WorkspaceServiceCollectionExtensions).Assembly)); - // NOTE: WorkspaceHostedService (Hosting namespace) is registered via AddHostedService in Task 24. + services.AddHostedService(); return services; } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs new file mode 100644 index 00000000..4c4eb2a8 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs @@ -0,0 +1,42 @@ +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Features.Workspace.Teardown; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +public sealed class WorkspaceReconcilerHealTests +{ + [Fact] + public async Task HealGuild_RecreatesDeletedChannel_WhenProvisioned() + { + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0); + var sut = harness.Build(); + await sut.ReconcileGlobalAsync(1); + + var channel = (await harness.Store.GetChannelsAsync(1, null))[0]; + harness.Gateway.ExternallyDeleteChannel(channel.DiscordChannelId); + + await sut.HealGuildAsync(1); + + Assert.True(harness.Gateway.ChannelExists(1, (await harness.Store.GetChannelsAsync(1, null))[0].DiscordChannelId)); + } + + [Fact] + public async Task HealGuild_DoesNotResurrect_AfterReset() + { + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0); + var sut = harness.Build(); + await sut.ReconcileGlobalAsync(1); + + var teardown = new WorkspaceTeardownService(harness.Gateway, harness.Store, new ProvisioningLock()); + await teardown.ResetGuildAsync(1); + + await sut.HealGuildAsync(1); + + Assert.Empty(harness.Gateway.CategoryIds); + Assert.Empty(harness.Gateway.ChannelIds); + Assert.Null(await harness.Store.GetCategoryAsync(1, null)); + } +} From 76a1ffeafdfa1baa903d9f399b240678d437c5a7 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 23:01:39 +0200 Subject: [PATCH 26/29] feat(host): wire AddWorkspace, config flag, and update running-locally docs --- docs/development/running-locally.md | 19 +++++++++++++------ src/RustPlusBot.Host/Program.cs | 4 ++++ src/RustPlusBot.Host/RustPlusBot.Host.csproj | 1 + src/RustPlusBot.Host/appsettings.json | 3 +++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/development/running-locally.md b/docs/development/running-locally.md index 39de6ce8..dc66b163 100644 --- a/docs/development/running-locally.md +++ b/docs/development/running-locally.md @@ -21,11 +21,18 @@ On first run the host applies the EF Core migration and creates `rustplusbot.db` in the working directory. -5. In the guild, use the slash commands: - - - `/server add name: ip: port:` - - `/server list` - - `/server remove id:` - - `/bind feature: channel:<#channel>` +5. In the guild, use the slash commands per the provisioning flow below. A missing or empty `Discord:Token` makes the host fail fast at startup with a clear `OptionsValidationException`. + +## Provisioning the workspace + +1. Invite the bot with the **Manage Channels**, **Manage Roles**, **Manage Messages**, and + **Embed Links** permissions. +2. In your test guild, run `/setup`. The bot creates a **RustPlusBot** category with + `#information`, `#setup`, and `#settings` channels and posts its anchored messages. +3. Re-running `/setup` is safe — it reconciles and repairs without creating duplicates. +4. Change the language from the selector in `#settings`. +5. In Development (`Workspace:EnableDangerCommands = true`), use + `/workspace simulate-server name: ip: port:` to create a server category, and + `/workspace reset` to delete the whole workspace. diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index d7237cf5..21357d5f 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -6,6 +6,7 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; using RustPlusBot.Discord; +using RustPlusBot.Features.Workspace; using RustPlusBot.Host.Credentials; using RustPlusBot.Persistence; @@ -24,6 +25,9 @@ builder.Services.AddSingleton(); builder.Services.AddBotPersistence(connectionString); builder.Services.AddDiscordBot(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Workspace")); +builder.Services.AddWorkspace(); var host = builder.Build(); diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj index 4bbbb043..2a4e261e 100644 --- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj +++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj @@ -19,5 +19,6 @@ + diff --git a/src/RustPlusBot.Host/appsettings.json b/src/RustPlusBot.Host/appsettings.json index 8d8e8e84..d94f9f2d 100644 --- a/src/RustPlusBot.Host/appsettings.json +++ b/src/RustPlusBot.Host/appsettings.json @@ -10,5 +10,8 @@ }, "Database": { "ConnectionString": "DataSource=rustplusbot.db" + }, + "Workspace": { + "EnableDangerCommands": false } } From 35e90a55415b17420931c60765bcac1f45150e86 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 23:08:41 +0200 Subject: [PATCH 27/29] =?UTF-8?q?refactor(workspace):=20address=20final=20?= =?UTF-8?q?review=20=E2=80=94=20heal=20all=20provisioned=20scopes,=20skip?= =?UTF-8?q?=20empty=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HealGuildAsync now heals whichever scopes are provisioned (global-if-present + each provisioned server category) instead of gating on the global category, so a per-server-only guild (e.g. simulate-server without /setup) self-heals; still no-ops when nothing is provisioned, preserving reset resurrection-safety. - EnsureMessagesAsync skips posting an empty payload (a renderer whose source entity vanished mid-reconcile) since Discord rejects a message with no content/embed/components. - Document the in-process event bus no-replay window on the ServerRegistered consumer. --- .../Hosting/WorkspaceHostedService.cs | 4 ++++ .../Reconciler/WorkspaceReconciler.cs | 24 +++++++++++++++---- .../WorkspaceReconcilerHealTests.cs | 21 ++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs index ffeab42a..10b2be37 100644 --- a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs +++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs @@ -100,6 +100,10 @@ private async Task OnChannelDestroyedAsync(SocketChannel channel) private async Task ConsumeServerRegisteredAsync(CancellationToken cancellationToken) { + // Subscription is registered when this loop first calls SubscribeAsync; the in-process bus does + // not replay, so events published before this point are not delivered. Fine here (the only 1a + // producer is the runtime-only simulate-server command); a real producer (1b FCM pairing) runs + // long after startup. try { await foreach (var registered in eventBus.SubscribeAsync(cancellationToken).ConfigureAwait(false)) diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs index 2a193bc5..afdaf8e9 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs @@ -63,8 +63,10 @@ public async Task HealGuildAsync(ulong guildId, CancellationToken cancellationTo { using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); - // Only heal an already-provisioned workspace; never resurrect one cleared by reset. - if (await store.GetCategoryAsync(guildId, null, cancellationToken).ConfigureAwait(false) is null) + // Heal only the scopes that are actually provisioned; never resurrect a workspace cleared by + // reset (no categories) and never create a scope the guild never asked for. + var categories = await store.GetAllCategoriesAsync(guildId, cancellationToken).ConfigureAwait(false); + if (categories.Count == 0) { return; } @@ -74,10 +76,14 @@ public async Task HealGuildAsync(ulong guildId, CancellationToken cancellationTo return; } - await ReconcileGlobalCoreAsync(guildId, cancellationToken).ConfigureAwait(false); - foreach (var server in await servers.ListAsync(guildId, cancellationToken).ConfigureAwait(false)) + if (categories.Any(c => c.RustServerId is null)) { - await ReconcileServerCoreAsync(guildId, server.Id, cancellationToken).ConfigureAwait(false); + await ReconcileGlobalCoreAsync(guildId, cancellationToken).ConfigureAwait(false); + } + + foreach (var category in categories.Where(c => c.RustServerId is not null)) + { + await ReconcileServerCoreAsync(guildId, category.RustServerId!.Value, cancellationToken).ConfigureAwait(false); } } @@ -185,6 +191,14 @@ private async Task EnsureMessagesAsync(ulong guildId, Guid? serverId, Dictionary } var payload = await renderer.RenderAsync(new MessageRenderContext(guildId, serverId, culture), cancellationToken).ConfigureAwait(false); + + // A renderer with nothing to show (e.g. the source entity vanished mid-reconcile) returns an + // empty payload; Discord rejects a message with no content/embed/components, so skip it. + if (payload.Text is null && payload.Embed is null && payload.Components is null) + { + continue; + } + var record = await store.GetMessageAsync(guildId, serverId, spec.Key, cancellationToken).ConfigureAwait(false); var canEditInPlace = record is not null diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs index 4c4eb2a8..0949f5f6 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs @@ -1,3 +1,5 @@ +using NSubstitute; +using RustPlusBot.Domain.Servers; using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Features.Workspace.Teardown; @@ -6,6 +8,25 @@ namespace RustPlusBot.Features.Workspace.Tests.Reconciler; public sealed class WorkspaceReconcilerHealTests { + [Fact] + public async Task HealGuild_HealsPerServerScope_EvenWithoutGlobal() + { + var serverId = Guid.NewGuid(); + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0); + harness.Servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.1.1.1", Port = 1 }); + var sut = harness.Build(); + await sut.ReconcileServerAsync(1, serverId); + + var channel = (await harness.Store.GetChannelsAsync(1, serverId))[0]; + harness.Gateway.ExternallyDeleteChannel(channel.DiscordChannelId); + + await sut.HealGuildAsync(1); + + Assert.True(harness.Gateway.ChannelExists(1, (await harness.Store.GetChannelsAsync(1, serverId))[0].DiscordChannelId)); + } + [Fact] public async Task HealGuild_RecreatesDeletedChannel_WhenProvisioned() { From 77d603b7b73e0c51db5d2a859471003473e8b764 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 23:10:49 +0200 Subject: [PATCH 28/29] style(workspace): apply dotnet format (pre-push hook) --- src/RustPlusBot.Discord/DiscordBotService.cs | 1 + .../Gateway/DiscordWorkspaceGateway.cs | 52 ++++++-- .../Gateway/IWorkspaceGateway.cs | 24 +++- .../Hosting/WorkspaceHostedService.cs | 12 +- .../Localization/LocalizationCatalog.cs | 6 +- .../Localization/Localizer.cs | 3 +- .../Messages/InformationMessageRenderer.cs | 7 +- .../Messages/ServerInfoMessageRenderer.cs | 3 +- .../Modules/WorkspaceAdminModule.cs | 6 +- .../Reconciler/IWorkspaceReconciler.cs | 4 +- .../Reconciler/ReconcileResult.cs | 3 +- .../Reconciler/WorkspaceReconciler.cs | 126 +++++++++++++----- .../Registry/WorkspaceRegistry.cs | 3 +- .../Specs/GlobalWorkspaceSpecProvider.cs | 9 +- .../Specs/ServerWorkspaceSpecProvider.cs | 3 +- .../Teardown/WorkspaceTeardownService.cs | 9 +- .../ProvisionedCategoryConfiguration.cs | 5 +- .../ProvisionedChannelConfiguration.cs | 5 +- .../ProvisionedMessageConfiguration.cs | 5 +- .../Workspace/IWorkspaceStore.cs | 16 ++- .../Workspace/WorkspaceStore.cs | 36 +++-- .../Fakes/FakeWorkspaceGateway.cs | 66 ++++++--- .../Fakes/FakeWorkspaceGatewayTests.cs | 3 +- .../Fakes/FakeWorkspaceStore.cs | 24 ++-- .../Messages/RendererTests.cs | 28 +++- .../Reconciler/ReconcilerHarness.cs | 19 ++- .../WorkspaceReconcilerAdoptTests.cs | 3 +- .../WorkspaceReconcilerHealTests.cs | 15 ++- .../WorkspaceReconcilerServerTests.cs | 9 +- .../Registry/WorkspaceRegistryTests.cs | 24 ++-- .../Teardown/WorkspaceTeardownServiceTests.cs | 9 +- .../WorkspaceRegistrationTests.cs | 5 +- .../Workspace/ProvisioningSchemaTests.cs | 5 +- .../Workspace/WorkspaceStoreTests.cs | 65 ++++++--- 34 files changed, 454 insertions(+), 159 deletions(-) diff --git a/src/RustPlusBot.Discord/DiscordBotService.cs b/src/RustPlusBot.Discord/DiscordBotService.cs index 002e77d9..b1ec971b 100644 --- a/src/RustPlusBot.Discord/DiscordBotService.cs +++ b/src/RustPlusBot.Discord/DiscordBotService.cs @@ -47,6 +47,7 @@ public async Task StartAsync(CancellationToken cancellationToken) { await interactions.AddModulesAsync(moduleAssembly.Assembly, services).ConfigureAwait(false); } + await client.LoginAsync(TokenType.Bot, _options.Token).ConfigureAwait(false); await client.StartAsync().ConfigureAwait(false); } diff --git a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs index 7a67ba83..ebbdb8e1 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs @@ -33,24 +33,38 @@ public bool ChannelExists(ulong guildId, ulong channelId) => client.GetGuild(guildId)?.GetTextChannel(channelId) is not null; /// - public Task FindChannelAsync(ulong guildId, ulong categoryId, string name, CancellationToken cancellationToken) + public Task FindChannelAsync(ulong guildId, + ulong categoryId, + string name, + CancellationToken cancellationToken) { var match = client.GetGuild(guildId)?.TextChannels - .FirstOrDefault(c => c.CategoryId == categoryId && string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(c => + c.CategoryId == categoryId && string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); return Task.FromResult(match?.Id); } /// - public async Task CreateChannelAsync(ulong guildId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken) + public async Task CreateChannelAsync(ulong guildId, + ulong categoryId, + string name, + ChannelPermissionProfile profile, + CancellationToken cancellationToken) { var guild = GetGuild(guildId); - var channel = await guild.CreateTextChannelAsync(name, props => props.CategoryId = categoryId).ConfigureAwait(false); + var channel = await guild.CreateTextChannelAsync(name, props => props.CategoryId = categoryId) + .ConfigureAwait(false); await ApplyOverwritesAsync(guild, channel, profile).ConfigureAwait(false); return channel.Id; } /// - public async Task ApplyChannelSettingsAsync(ulong guildId, ulong channelId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken) + public async Task ApplyChannelSettingsAsync(ulong guildId, + ulong channelId, + ulong categoryId, + string name, + ChannelPermissionProfile profile, + CancellationToken cancellationToken) { var guild = client.GetGuild(guildId); var channel = guild?.GetTextChannel(channelId); @@ -72,7 +86,10 @@ await channel.ModifyAsync(props => } /// - public async Task MessageExistsAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken) + public async Task MessageExistsAsync(ulong guildId, + ulong channelId, + ulong messageId, + CancellationToken cancellationToken) { var channel = client.GetGuild(guildId)?.GetTextChannel(channelId); if (channel is null) @@ -85,17 +102,26 @@ public async Task MessageExistsAsync(ulong guildId, ulong channelId, ulong } /// - public async Task PostMessageAsync(ulong guildId, ulong channelId, MessagePayload payload, CancellationToken cancellationToken) + public async Task PostMessageAsync(ulong guildId, + ulong channelId, + MessagePayload payload, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(payload); var channel = client.GetGuild(guildId)?.GetTextChannel(channelId) - ?? throw new InvalidOperationException($"Channel {channelId} not found in guild {guildId}."); - var message = await channel.SendMessageAsync(text: payload.Text, embed: payload.Embed, components: payload.Components).ConfigureAwait(false); + ?? throw new InvalidOperationException($"Channel {channelId} not found in guild {guildId}."); + var message = await channel + .SendMessageAsync(text: payload.Text, embed: payload.Embed, components: payload.Components) + .ConfigureAwait(false); return message.Id; } /// - public async Task EditMessageAsync(ulong guildId, ulong channelId, ulong messageId, MessagePayload payload, CancellationToken cancellationToken) + public async Task EditMessageAsync(ulong guildId, + ulong channelId, + ulong messageId, + MessagePayload payload, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(payload); var channel = client.GetGuild(guildId)?.GetTextChannel(channelId); @@ -144,11 +170,17 @@ public IReadOnlyList GetMissingBotPermissions(ulong guildId) var permissions = guild.CurrentUser.GuildPermissions; var missing = new List(); if (!permissions.ManageChannels) { missing.Add("Manage Channels"); } + if (!permissions.ManageRoles) { missing.Add("Manage Roles"); } + if (!permissions.SendMessages) { missing.Add("Send Messages"); } + if (!permissions.EmbedLinks) { missing.Add("Embed Links"); } + if (!permissions.ManageMessages) { missing.Add("Manage Messages"); } + if (!permissions.ViewChannel) { missing.Add("View Channels"); } + return missing; } diff --git a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs index f49ca96a..add747be 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs @@ -46,7 +46,11 @@ internal interface IWorkspaceGateway /// The permission profile to apply to the channel. /// Token to cancel the operation. /// The snowflake ID of the newly created channel. - Task CreateChannelAsync(ulong guildId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken); + Task CreateChannelAsync(ulong guildId, + ulong categoryId, + string name, + ChannelPermissionProfile profile, + CancellationToken cancellationToken); /// Re-applies parent + name + permission profile to an existing channel (adopt/heal path). /// The snowflake ID of the guild. @@ -55,7 +59,12 @@ internal interface IWorkspaceGateway /// The name to set on the channel. /// The permission profile to re-apply. /// Token to cancel the operation. - Task ApplyChannelSettingsAsync(ulong guildId, ulong channelId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken); + Task ApplyChannelSettingsAsync(ulong guildId, + ulong channelId, + ulong categoryId, + string name, + ChannelPermissionProfile profile, + CancellationToken cancellationToken); /// True if the message still exists in the channel. /// The snowflake ID of the guild. @@ -71,7 +80,10 @@ internal interface IWorkspaceGateway /// The content of the message to post. /// Token to cancel the operation. /// The snowflake ID of the newly posted message. - Task PostMessageAsync(ulong guildId, ulong channelId, MessagePayload payload, CancellationToken cancellationToken); + Task PostMessageAsync(ulong guildId, + ulong channelId, + MessagePayload payload, + CancellationToken cancellationToken); /// Edits an existing message in place. /// The snowflake ID of the guild. @@ -79,7 +91,11 @@ internal interface IWorkspaceGateway /// The snowflake ID of the message to edit. /// The new content to apply to the message. /// Token to cancel the operation. - Task EditMessageAsync(ulong guildId, ulong channelId, ulong messageId, MessagePayload payload, CancellationToken cancellationToken); + Task EditMessageAsync(ulong guildId, + ulong channelId, + ulong messageId, + MessagePayload payload, + CancellationToken cancellationToken); /// Deletes a channel by snowflake (no-op if already gone). /// The snowflake ID of the guild. diff --git a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs index 10b2be37..e81c2712 100644 --- a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs +++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs @@ -23,6 +23,9 @@ internal sealed class WorkspaceHostedService( private Task? _eventLoop; private bool _startupDone; + /// + public void Dispose() => _cts.Dispose(); + /// public Task StartAsync(CancellationToken cancellationToken) { @@ -53,9 +56,6 @@ public async Task StopAsync(CancellationToken cancellationToken) } } - /// - public void Dispose() => _cts.Dispose(); - private async Task OnReadyAsync() { if (_startupDone) @@ -106,13 +106,15 @@ private async Task ConsumeServerRegisteredAsync(CancellationToken cancellationTo // long after startup. try { - await foreach (var registered in eventBus.SubscribeAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var registered in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) { var scope = scopeFactory.CreateAsyncScope(); await using (scope.ConfigureAwait(false)) { var reconciler = scope.ServiceProvider.GetRequiredService(); - await reconciler.ReconcileServerAsync(registered.GuildId, registered.ServerId, cancellationToken).ConfigureAwait(false); + await reconciler.ReconcileServerAsync(registered.GuildId, registered.ServerId, cancellationToken) + .ConfigureAwait(false); } } } diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs index f780340f..5b9cecaa 100644 --- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs @@ -22,7 +22,8 @@ internal sealed class LocalizationCatalog ["information.body"] = "Connect your Rust+ account in #setup, then pair a server in-game to begin.", ["information.servers"] = "Servers registered: {0}", ["setup.title"] = "Connect your Rust+ account", - ["setup.body"] = "Account connection arrives in the next update. Once connected, pair a server in-game and its channels appear automatically.", + ["setup.body"] = + "Account connection arrives in the next update. Once connected, pair a server in-game and its channels appear automatically.", ["settings.title"] = "Settings", ["settings.body"] = "Configure the bot for this server.", ["settings.language.label"] = "Language", @@ -37,7 +38,8 @@ internal sealed class LocalizationCatalog ["channel.settings.name"] = "parametres", ["channel.info.name"] = "info", ["information.title"] = "RustPlusBot", - ["information.body"] = "Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.", + ["information.body"] = + "Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.", ["information.servers"] = "Serveurs enregistres : {0}", ["setup.title"] = "Connectez votre compte Rust+", ["setup.body"] = "La connexion de compte arrive dans la prochaine mise a jour.", diff --git a/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs b/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs index 4b2efe9f..4ca7f3d1 100644 --- a/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs +++ b/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs @@ -17,7 +17,8 @@ public string Get(string key, string culture) return value; } - if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) && fallback.TryGetValue(key, out var fallbackValue)) + if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) && + fallback.TryGetValue(key, out var fallbackValue)) { return fallbackValue; } diff --git a/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs index 3e4b1724..e44810ac 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs @@ -15,13 +15,14 @@ internal sealed class InformationMessageRenderer(IServerService servers, ILocali public string MessageKey => WorkspaceMessageKeys.InformationMain; /// - public async ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); var count = (await servers.ListAsync(context.GuildId, cancellationToken).ConfigureAwait(false)).Count; var description = localizer.Get("information.body", context.Culture) - + "\n\n" - + localizer.Get("information.servers", context.Culture, count); + + "\n\n" + + localizer.Get("information.servers", context.Culture, count); var embed = new EmbedBuilder() .WithTitle(localizer.Get("information.title", context.Culture)) .WithDescription(description) diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs index a85455da..ef54731e 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs @@ -16,7 +16,8 @@ internal sealed class ServerInfoMessageRenderer(IServerService servers, ILocaliz public string MessageKey => WorkspaceMessageKeys.ServerInfo; /// - public async ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); if (context.ServerId is not Guid serverId) diff --git a/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs b/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs index b598e214..cf44bb14 100644 --- a/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs +++ b/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs @@ -80,9 +80,11 @@ public async Task SimulateServerAsync(string name, string ip, int port) try { var servers = scope.ServiceProvider.GetRequiredService(); - var server = await servers.AddAsync(Context.Guild.Id, Context.User.Id, name, ip, port).ConfigureAwait(false); + var server = await servers.AddAsync(Context.Guild.Id, Context.User.Id, name, ip, port) + .ConfigureAwait(false); await eventBus.PublishAsync(new ServerRegisteredEvent(Context.Guild.Id, server.Id)).ConfigureAwait(false); - await FollowupAsync($"Registered **{name}** and published ServerRegisteredEvent.", ephemeral: true).ConfigureAwait(false); + await FollowupAsync($"Registered **{name}** and published ServerRegisteredEvent.", ephemeral: true) + .ConfigureAwait(false); } finally { diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs index a84aed0b..faa89bdb 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs @@ -14,7 +14,9 @@ internal interface IWorkspaceReconciler /// The server to reconcile. /// A cancellation token. /// The reconcile result. - Task ReconcileServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + Task ReconcileServerAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken = default); /// Converges an already-provisioned guild (global + all servers). No-op if not provisioned. /// The guild to heal. diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs b/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs index f36a911a..939b64e9 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs @@ -27,5 +27,6 @@ internal sealed record ReconcileResult(ReconcileStatus Status, IReadOnlyListBuilds a missing-permissions result. /// The missing permission names. /// A missing-permissions result. - public static ReconcileResult Missing(IReadOnlyList permissions) => new(ReconcileStatus.MissingPermissions, permissions); + public static ReconcileResult Missing(IReadOnlyList permissions) => + new(ReconcileStatus.MissingPermissions, permissions); } diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs index afdaf8e9..c5f105d5 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs @@ -31,7 +31,8 @@ internal sealed class WorkspaceReconciler( renderers.ToDictionary(r => r.MessageKey, StringComparer.Ordinal); /// - public async Task ReconcileGlobalAsync(ulong guildId, CancellationToken cancellationToken = default) + public async Task ReconcileGlobalAsync(ulong guildId, + CancellationToken cancellationToken = default) { using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); var missing = gateway.GetMissingBotPermissions(guildId); @@ -45,7 +46,9 @@ public async Task ReconcileGlobalAsync(ulong guildId, Cancellat } /// - public async Task ReconcileServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) + public async Task ReconcileServerAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken = default) { using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); var missing = gateway.GetMissingBotPermissions(guildId); @@ -83,7 +86,8 @@ public async Task HealGuildAsync(ulong guildId, CancellationToken cancellationTo foreach (var category in categories.Where(c => c.RustServerId is not null)) { - await ReconcileServerCoreAsync(guildId, category.RustServerId!.Value, cancellationToken).ConfigureAwait(false); + await ReconcileServerCoreAsync(guildId, category.RustServerId!.Value, cancellationToken) + .ConfigureAwait(false); } } @@ -91,7 +95,8 @@ private async Task ReconcileGlobalCoreAsync(ulong guildId, CancellationToken can { var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); var categoryName = localizer.Get("category.global.name", culture); - await ReconcileScopeAsync(guildId, null, categoryName, culture, WorkspaceScope.Global, cancellationToken).ConfigureAwait(false); + await ReconcileScopeAsync(guildId, null, categoryName, culture, WorkspaceScope.Global, cancellationToken) + .ConfigureAwait(false); } private async Task ReconcileServerCoreAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) @@ -99,23 +104,36 @@ private async Task ReconcileServerCoreAsync(ulong guildId, Guid serverId, var server = await servers.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); if (server is null) { - logger.LogWarning("ReconcileServer skipped: server {ServerId} not found in guild {GuildId}.", serverId, guildId); + logger.LogWarning("ReconcileServer skipped: server {ServerId} not found in guild {GuildId}.", serverId, + guildId); return false; } var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); - await ReconcileScopeAsync(guildId, serverId, server.Name, culture, WorkspaceScope.PerServer, cancellationToken).ConfigureAwait(false); + await ReconcileScopeAsync(guildId, serverId, server.Name, culture, WorkspaceScope.PerServer, cancellationToken) + .ConfigureAwait(false); return true; } - private async Task ReconcileScopeAsync(ulong guildId, Guid? serverId, string categoryName, string culture, WorkspaceScope scope, CancellationToken cancellationToken) + private async Task ReconcileScopeAsync(ulong guildId, + Guid? serverId, + string categoryName, + string culture, + WorkspaceScope scope, + CancellationToken cancellationToken) { - var categoryId = await EnsureCategoryAsync(guildId, serverId, categoryName, cancellationToken).ConfigureAwait(false); - var channelIds = await EnsureChannelsAsync(guildId, serverId, categoryId, culture, scope, cancellationToken).ConfigureAwait(false); - await EnsureMessagesAsync(guildId, serverId, channelIds, culture, scope, cancellationToken).ConfigureAwait(false); + var categoryId = await EnsureCategoryAsync(guildId, serverId, categoryName, cancellationToken) + .ConfigureAwait(false); + var channelIds = await EnsureChannelsAsync(guildId, serverId, categoryId, culture, scope, cancellationToken) + .ConfigureAwait(false); + await EnsureMessagesAsync(guildId, serverId, channelIds, culture, scope, cancellationToken) + .ConfigureAwait(false); } - private async Task EnsureCategoryAsync(ulong guildId, Guid? serverId, string name, CancellationToken cancellationToken) + private async Task EnsureCategoryAsync(ulong guildId, + Guid? serverId, + string name, + CancellationToken cancellationToken) { var record = await store.GetCategoryAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); if (record is not null && gateway.CategoryExists(guildId, record.DiscordCategoryId)) @@ -124,14 +142,23 @@ private async Task EnsureCategoryAsync(ulong guildId, Guid? serverId, str } var adopted = await gateway.FindCategoryAsync(guildId, name, cancellationToken).ConfigureAwait(false); - var categoryId = adopted ?? await gateway.CreateCategoryAsync(guildId, name, cancellationToken).ConfigureAwait(false); + var categoryId = adopted ?? + await gateway.CreateCategoryAsync(guildId, name, cancellationToken).ConfigureAwait(false); await store.SaveCategoryAsync( - new ProvisionedCategory { GuildId = guildId, RustServerId = serverId, DiscordCategoryId = categoryId }, + new ProvisionedCategory + { + GuildId = guildId, RustServerId = serverId, DiscordCategoryId = categoryId + }, cancellationToken).ConfigureAwait(false); return categoryId; } - private async Task> EnsureChannelsAsync(ulong guildId, Guid? serverId, ulong categoryId, string culture, WorkspaceScope scope, CancellationToken cancellationToken) + private async Task> EnsureChannelsAsync(ulong guildId, + Guid? serverId, + ulong categoryId, + string culture, + WorkspaceScope scope, + CancellationToken cancellationToken) { var existing = (await store.GetChannelsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false)) .ToDictionary(c => c.ChannelKey, StringComparer.Ordinal); @@ -146,23 +173,33 @@ private async Task> EnsureChannelsAsync(ulong guildId, if (existing.TryGetValue(spec.Key, out var rec) && gateway.ChannelExists(guildId, rec.DiscordChannelId)) { channelId = rec.DiscordChannelId; - await gateway.ApplyChannelSettingsAsync(guildId, channelId, categoryId, name, spec.Permissions, cancellationToken).ConfigureAwait(false); + await gateway + .ApplyChannelSettingsAsync(guildId, channelId, categoryId, name, spec.Permissions, + cancellationToken).ConfigureAwait(false); } else { - var adopted = await gateway.FindChannelAsync(guildId, categoryId, name, cancellationToken).ConfigureAwait(false); + var adopted = await gateway.FindChannelAsync(guildId, categoryId, name, cancellationToken) + .ConfigureAwait(false); if (adopted is ulong adoptedId) { channelId = adoptedId; - await gateway.ApplyChannelSettingsAsync(guildId, channelId, categoryId, name, spec.Permissions, cancellationToken).ConfigureAwait(false); + await gateway + .ApplyChannelSettingsAsync(guildId, channelId, categoryId, name, spec.Permissions, + cancellationToken).ConfigureAwait(false); } else { - channelId = await gateway.CreateChannelAsync(guildId, categoryId, name, spec.Permissions, cancellationToken).ConfigureAwait(false); + channelId = await gateway + .CreateChannelAsync(guildId, categoryId, name, spec.Permissions, cancellationToken) + .ConfigureAwait(false); } await store.SaveChannelAsync( - new ProvisionedChannel { GuildId = guildId, RustServerId = serverId, ChannelKey = spec.Key, DiscordChannelId = channelId }, + new ProvisionedChannel + { + GuildId = guildId, RustServerId = serverId, ChannelKey = spec.Key, DiscordChannelId = channelId + }, cancellationToken).ConfigureAwait(false); } @@ -174,23 +211,33 @@ await store.SaveChannelAsync( { foreach (var orphan in existing.Keys.Where(k => !registryKeys.Contains(k))) { - logger.LogInformation("Retaining provisioned channel '{Key}' no longer in the registry (guild {GuildId}).", orphan, guildId); + logger.LogInformation( + "Retaining provisioned channel '{Key}' no longer in the registry (guild {GuildId}).", orphan, + guildId); } } return result; } - private async Task EnsureMessagesAsync(ulong guildId, Guid? serverId, Dictionary channelIds, string culture, WorkspaceScope scope, CancellationToken cancellationToken) + private async Task EnsureMessagesAsync(ulong guildId, + Guid? serverId, + Dictionary channelIds, + string culture, + WorkspaceScope scope, + CancellationToken cancellationToken) { foreach (var spec in registry.GetMessageSpecs(scope)) { - if (!channelIds.TryGetValue(spec.ChannelKey, out var channelId) || !_renderers.TryGetValue(spec.Key, out var renderer)) + if (!channelIds.TryGetValue(spec.ChannelKey, out var channelId) || + !_renderers.TryGetValue(spec.Key, out var renderer)) { continue; } - var payload = await renderer.RenderAsync(new MessageRenderContext(guildId, serverId, culture), cancellationToken).ConfigureAwait(false); + var payload = await renderer + .RenderAsync(new MessageRenderContext(guildId, serverId, culture), cancellationToken) + .ConfigureAwait(false); // A renderer with nothing to show (e.g. the source entity vanished mid-reconcile) returns an // empty payload; Discord rejects a message with no content/embed/components, so skip it. @@ -199,24 +246,43 @@ private async Task EnsureMessagesAsync(ulong guildId, Guid? serverId, Dictionary continue; } - var record = await store.GetMessageAsync(guildId, serverId, spec.Key, cancellationToken).ConfigureAwait(false); + var record = await store.GetMessageAsync(guildId, serverId, spec.Key, cancellationToken) + .ConfigureAwait(false); var canEditInPlace = record is not null - && record.DiscordChannelId == channelId - && await gateway.MessageExistsAsync(guildId, channelId, record.DiscordMessageId, cancellationToken).ConfigureAwait(false); + && record.DiscordChannelId == channelId + && await gateway + .MessageExistsAsync(guildId, channelId, record.DiscordMessageId, cancellationToken) + .ConfigureAwait(false); if (canEditInPlace) { - await gateway.EditMessageAsync(guildId, channelId, record!.DiscordMessageId, payload, cancellationToken).ConfigureAwait(false); + await gateway.EditMessageAsync(guildId, channelId, record!.DiscordMessageId, payload, cancellationToken) + .ConfigureAwait(false); await store.SaveMessageAsync( - new ProvisionedMessage { GuildId = guildId, RustServerId = serverId, MessageKey = spec.Key, DiscordChannelId = channelId, DiscordMessageId = record.DiscordMessageId }, + new ProvisionedMessage + { + GuildId = guildId, + RustServerId = serverId, + MessageKey = spec.Key, + DiscordChannelId = channelId, + DiscordMessageId = record.DiscordMessageId + }, cancellationToken).ConfigureAwait(false); } else { - var messageId = await gateway.PostMessageAsync(guildId, channelId, payload, cancellationToken).ConfigureAwait(false); + var messageId = await gateway.PostMessageAsync(guildId, channelId, payload, cancellationToken) + .ConfigureAwait(false); await store.SaveMessageAsync( - new ProvisionedMessage { GuildId = guildId, RustServerId = serverId, MessageKey = spec.Key, DiscordChannelId = channelId, DiscordMessageId = messageId }, + new ProvisionedMessage + { + GuildId = guildId, + RustServerId = serverId, + MessageKey = spec.Key, + DiscordChannelId = channelId, + DiscordMessageId = messageId + }, cancellationToken).ConfigureAwait(false); } } diff --git a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs index fca40323..9733af54 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs @@ -12,7 +12,8 @@ internal sealed class WorkspaceRegistry( /// public IReadOnlyList GetChannelSpecs(WorkspaceScope scope) => - _channels.Where(s => s.Scope == scope).OrderBy(s => s.Order).ThenBy(s => s.Key, StringComparer.Ordinal).ToList(); + _channels.Where(s => s.Scope == scope).OrderBy(s => s.Order).ThenBy(s => s.Key, StringComparer.Ordinal) + .ToList(); /// public IReadOnlyList GetMessageSpecs(WorkspaceScope scope) => diff --git a/src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs index cc123ebc..c6d05227 100644 --- a/src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs +++ b/src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs @@ -8,9 +8,12 @@ internal sealed class GlobalWorkspaceSpecProvider : IChannelSpecProvider, IMessa /// public IEnumerable GetChannelSpecs() => [ - new(WorkspaceScope.Global, WorkspaceChannelKeys.Information, "channel.information.name", ChannelPermissionProfile.ReadOnly, 0), - new(WorkspaceScope.Global, WorkspaceChannelKeys.Setup, "channel.setup.name", ChannelPermissionProfile.ReadOnly, 1), - new(WorkspaceScope.Global, WorkspaceChannelKeys.Settings, "channel.settings.name", ChannelPermissionProfile.ReadOnly, 2), + new(WorkspaceScope.Global, WorkspaceChannelKeys.Information, "channel.information.name", + ChannelPermissionProfile.ReadOnly, 0), + new(WorkspaceScope.Global, WorkspaceChannelKeys.Setup, "channel.setup.name", ChannelPermissionProfile.ReadOnly, + 1), + new(WorkspaceScope.Global, WorkspaceChannelKeys.Settings, "channel.settings.name", + ChannelPermissionProfile.ReadOnly, 2), ]; /// diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs index fb22189b..9863e8c4 100644 --- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs +++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs @@ -8,7 +8,8 @@ internal sealed class ServerWorkspaceSpecProvider : IChannelSpecProvider, IMessa /// public IEnumerable GetChannelSpecs() => [ - new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerInfo, "channel.info.name", ChannelPermissionProfile.ReadOnly, 0), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerInfo, "channel.info.name", + ChannelPermissionProfile.ReadOnly, 0), ]; /// diff --git a/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs index 25e8951c..0fe5d9fe 100644 --- a/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs +++ b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs @@ -33,15 +33,18 @@ public async Task ResetGuildAsync(ulong guildId, CancellationToken cancellationT private async Task DeleteScopeAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken) { - foreach (var channel in await store.GetChannelsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false)) + foreach (var channel in await store.GetChannelsAsync(guildId, serverId, cancellationToken) + .ConfigureAwait(false)) { - await gateway.DeleteChannelAsync(guildId, channel.DiscordChannelId, cancellationToken).ConfigureAwait(false); + await gateway.DeleteChannelAsync(guildId, channel.DiscordChannelId, cancellationToken) + .ConfigureAwait(false); } var category = await store.GetCategoryAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); if (category is not null) { - await gateway.DeleteCategoryAsync(guildId, category.DiscordCategoryId, cancellationToken).ConfigureAwait(false); + await gateway.DeleteCategoryAsync(guildId, category.DiscordCategoryId, cancellationToken) + .ConfigureAwait(false); } await store.DeleteScopeAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); diff --git a/src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs index a57a469b..3fb724ff 100644 --- a/src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs +++ b/src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs @@ -11,7 +11,10 @@ public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.HasKey(c => c.Id); - builder.HasIndex(c => new { c.GuildId, c.RustServerId }).IsUnique(); + builder.HasIndex(c => new + { + c.GuildId, c.RustServerId + }).IsUnique(); builder.HasOne() .WithMany() .HasForeignKey(c => c.RustServerId) diff --git a/src/RustPlusBot.Persistence/Configurations/ProvisionedChannelConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ProvisionedChannelConfiguration.cs index f036c3dd..d4109549 100644 --- a/src/RustPlusBot.Persistence/Configurations/ProvisionedChannelConfiguration.cs +++ b/src/RustPlusBot.Persistence/Configurations/ProvisionedChannelConfiguration.cs @@ -12,7 +12,10 @@ public void Configure(EntityTypeBuilder builder) ArgumentNullException.ThrowIfNull(builder); builder.HasKey(c => c.Id); builder.Property(c => c.ChannelKey).IsRequired().HasMaxLength(64); - builder.HasIndex(c => new { c.GuildId, c.RustServerId, c.ChannelKey }).IsUnique(); + builder.HasIndex(c => new + { + c.GuildId, c.RustServerId, c.ChannelKey + }).IsUnique(); builder.HasOne() .WithMany() .HasForeignKey(c => c.RustServerId) diff --git a/src/RustPlusBot.Persistence/Configurations/ProvisionedMessageConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ProvisionedMessageConfiguration.cs index 1b71c3d9..c6dfafba 100644 --- a/src/RustPlusBot.Persistence/Configurations/ProvisionedMessageConfiguration.cs +++ b/src/RustPlusBot.Persistence/Configurations/ProvisionedMessageConfiguration.cs @@ -12,7 +12,10 @@ public void Configure(EntityTypeBuilder builder) ArgumentNullException.ThrowIfNull(builder); builder.HasKey(m => m.Id); builder.Property(m => m.MessageKey).IsRequired().HasMaxLength(64); - builder.HasIndex(m => new { m.GuildId, m.RustServerId, m.MessageKey }).IsUnique(); + builder.HasIndex(m => new + { + m.GuildId, m.RustServerId, m.MessageKey + }).IsUnique(); builder.HasOne() .WithMany() .HasForeignKey(m => m.RustServerId) diff --git a/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs b/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs index 19023c9f..55ad44df 100644 --- a/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs +++ b/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs @@ -10,7 +10,9 @@ public interface IWorkspaceStore /// The Rust server id, or null for the global scope. /// A cancellation token. /// The matching category, or null if none exists. - Task GetCategoryAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default); + Task GetCategoryAsync(ulong guildId, + Guid? serverId, + CancellationToken cancellationToken = default); /// Upserts the category for its scope (keyed by guild + server). /// The category to save. @@ -22,7 +24,9 @@ public interface IWorkspaceStore /// The Rust server id, or null for the global scope. /// A cancellation token. /// The provisioned channels for the scope. - Task> GetChannelsAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default); + Task> GetChannelsAsync(ulong guildId, + Guid? serverId, + CancellationToken cancellationToken = default); /// Upserts a channel for its scope (keyed by guild + server + channel key). /// The channel to save. @@ -35,7 +39,10 @@ public interface IWorkspaceStore /// The stable spec key (e.g. "information.main"). /// A cancellation token. /// The matching message, or null if none exists. - Task GetMessageAsync(ulong guildId, Guid? serverId, string messageKey, CancellationToken cancellationToken = default); + Task GetMessageAsync(ulong guildId, + Guid? serverId, + string messageKey, + CancellationToken cancellationToken = default); /// Upserts an anchored message (keyed by guild + server + message key). /// The message to save. @@ -64,7 +71,8 @@ public interface IWorkspaceStore /// The Discord guild snowflake. /// A cancellation token. /// All provisioned categories for the guild. - Task> GetAllCategoriesAsync(ulong guildId, CancellationToken cancellationToken = default); + Task> GetAllCategoriesAsync(ulong guildId, + CancellationToken cancellationToken = default); /// Gets the distinct guild ids that have any provisioned category (for startup reconcile). /// A cancellation token. diff --git a/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs index 0fd66967..1a7eaa7e 100644 --- a/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs +++ b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs @@ -11,7 +11,9 @@ namespace RustPlusBot.Persistence.Workspace; public sealed class WorkspaceStore(BotDbContext context, IClock clock) : IWorkspaceStore { /// - public Task GetCategoryAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) => + public Task GetCategoryAsync(ulong guildId, + Guid? serverId, + CancellationToken cancellationToken = default) => context.ProvisionedCategories .SingleOrDefaultAsync(c => c.GuildId == guildId && c.RustServerId == serverId, cancellationToken); @@ -20,7 +22,8 @@ public async Task SaveCategoryAsync(ProvisionedCategory category, CancellationTo { ArgumentNullException.ThrowIfNull(category); var existing = await context.ProvisionedCategories - .SingleOrDefaultAsync(c => c.GuildId == category.GuildId && c.RustServerId == category.RustServerId, cancellationToken) + .SingleOrDefaultAsync(c => c.GuildId == category.GuildId && c.RustServerId == category.RustServerId, + cancellationToken) .ConfigureAwait(false); if (existing is null) @@ -37,7 +40,9 @@ public async Task SaveCategoryAsync(ProvisionedCategory category, CancellationTo } /// - public async Task> GetChannelsAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) => + public async Task> GetChannelsAsync(ulong guildId, + Guid? serverId, + CancellationToken cancellationToken = default) => await context.ProvisionedChannels .Where(c => c.GuildId == guildId && c.RustServerId == serverId) .ToListAsync(cancellationToken) @@ -49,7 +54,8 @@ public async Task SaveChannelAsync(ProvisionedChannel channel, CancellationToken ArgumentNullException.ThrowIfNull(channel); var existing = await context.ProvisionedChannels .SingleOrDefaultAsync( - c => c.GuildId == channel.GuildId && c.RustServerId == channel.RustServerId && c.ChannelKey == channel.ChannelKey, + c => c.GuildId == channel.GuildId && c.RustServerId == channel.RustServerId && + c.ChannelKey == channel.ChannelKey, cancellationToken) .ConfigureAwait(false); @@ -67,9 +73,13 @@ public async Task SaveChannelAsync(ProvisionedChannel channel, CancellationToken } /// - public Task GetMessageAsync(ulong guildId, Guid? serverId, string messageKey, CancellationToken cancellationToken = default) => + public Task GetMessageAsync(ulong guildId, + Guid? serverId, + string messageKey, + CancellationToken cancellationToken = default) => context.ProvisionedMessages - .SingleOrDefaultAsync(m => m.GuildId == guildId && m.RustServerId == serverId && m.MessageKey == messageKey, cancellationToken); + .SingleOrDefaultAsync(m => m.GuildId == guildId && m.RustServerId == serverId && m.MessageKey == messageKey, + cancellationToken); /// public async Task SaveMessageAsync(ProvisionedMessage message, CancellationToken cancellationToken = default) @@ -77,7 +87,8 @@ public async Task SaveMessageAsync(ProvisionedMessage message, CancellationToken ArgumentNullException.ThrowIfNull(message); var existing = await context.ProvisionedMessages .SingleOrDefaultAsync( - m => m.GuildId == message.GuildId && m.RustServerId == message.RustServerId && m.MessageKey == message.MessageKey, + m => m.GuildId == message.GuildId && m.RustServerId == message.RustServerId && + m.MessageKey == message.MessageKey, cancellationToken) .ConfigureAwait(false); @@ -115,7 +126,10 @@ public async Task SetCultureAsync(ulong guildId, string culture, CancellationTok if (settings is null) { - context.GuildSettings.Add(new GuildSettings { GuildId = guildId, Culture = culture }); + context.GuildSettings.Add(new GuildSettings + { + GuildId = guildId, Culture = culture + }); } else { @@ -140,14 +154,16 @@ await context.ProvisionedCategories } /// - public async Task> GetAllCategoriesAsync(ulong guildId, CancellationToken cancellationToken = default) => + public async Task> GetAllCategoriesAsync(ulong guildId, + CancellationToken cancellationToken = default) => await context.ProvisionedCategories .Where(c => c.GuildId == guildId) .ToListAsync(cancellationToken) .ConfigureAwait(false); /// - public async Task> GetProvisionedGuildIdsAsync(CancellationToken cancellationToken = default) => + public async Task> + GetProvisionedGuildIdsAsync(CancellationToken cancellationToken = default) => await context.ProvisionedCategories .Select(c => c.GuildId) .Distinct() diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs index 75aced2e..53eb11af 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs @@ -7,10 +7,6 @@ namespace RustPlusBot.Features.Workspace.Tests.Fakes; /// Deterministic in-memory for reconciler tests. internal sealed class FakeWorkspaceGateway : IWorkspaceGateway { - private sealed record Category(ulong Id, string Name); - private sealed record Channel(ulong Id, ulong CategoryId, string Name, ChannelPermissionProfile Profile); - private sealed record Message(ulong Id, ulong ChannelId, MessagePayload Payload); - private readonly ConcurrentDictionary _categories = new(); private readonly ConcurrentDictionary _channels = new(); private readonly ConcurrentDictionary _messages = new(); @@ -21,22 +17,15 @@ private sealed record Message(ulong Id, ulong ChannelId, MessagePayload Payload) public int CreatedChannels { get; private set; } public int PostedMessages { get; private set; } public int EditedMessages { get; private set; } - - private ulong NextId() => Interlocked.Increment(ref _nextId); - - public void ExternallyDeleteChannel(ulong channelId) => _channels.TryRemove(channelId, out _); - public void ExternallyDeleteCategory(ulong categoryId) => _categories.TryRemove(categoryId, out _); - public void ExternallyDeleteMessage(ulong messageId) => _messages.TryRemove(messageId, out _); - public MessagePayload? GetMessagePayload(ulong messageId) => _messages.TryGetValue(messageId, out var m) ? m.Payload : null; public IReadOnlyCollection ChannelIds => _channels.Keys.ToList(); public IReadOnlyCollection CategoryIds => _categories.Keys.ToList(); - public ChannelPermissionProfile ProfileOf(ulong channelId) => _channels[channelId].Profile; public bool CategoryExists(ulong guildId, ulong categoryId) => _categories.ContainsKey(categoryId); public Task FindCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken) { - var match = _categories.Values.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); + var match = _categories.Values.FirstOrDefault(c => + string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); return Task.FromResult(match is null ? (ulong?)null : match.Id); } @@ -50,14 +39,21 @@ public Task CreateCategoryAsync(ulong guildId, string name, CancellationT public bool ChannelExists(ulong guildId, ulong channelId) => _channels.ContainsKey(channelId); - public Task FindChannelAsync(ulong guildId, ulong categoryId, string name, CancellationToken cancellationToken) + public Task FindChannelAsync(ulong guildId, + ulong categoryId, + string name, + CancellationToken cancellationToken) { var match = _channels.Values.FirstOrDefault(c => c.CategoryId == categoryId && string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); return Task.FromResult(match is null ? (ulong?)null : match.Id); } - public Task CreateChannelAsync(ulong guildId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken) + public Task CreateChannelAsync(ulong guildId, + ulong categoryId, + string name, + ChannelPermissionProfile profile, + CancellationToken cancellationToken) { var id = NextId(); _channels[id] = new Channel(id, categoryId, name, profile); @@ -65,16 +61,27 @@ public Task CreateChannelAsync(ulong guildId, ulong categoryId, string na return Task.FromResult(id); } - public Task ApplyChannelSettingsAsync(ulong guildId, ulong channelId, ulong categoryId, string name, ChannelPermissionProfile profile, CancellationToken cancellationToken) + public Task ApplyChannelSettingsAsync(ulong guildId, + ulong channelId, + ulong categoryId, + string name, + ChannelPermissionProfile profile, + CancellationToken cancellationToken) { _channels[channelId] = new Channel(channelId, categoryId, name, profile); return Task.CompletedTask; } - public Task MessageExistsAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken) => + public Task MessageExistsAsync(ulong guildId, + ulong channelId, + ulong messageId, + CancellationToken cancellationToken) => Task.FromResult(_messages.ContainsKey(messageId)); - public Task PostMessageAsync(ulong guildId, ulong channelId, MessagePayload payload, CancellationToken cancellationToken) + public Task PostMessageAsync(ulong guildId, + ulong channelId, + MessagePayload payload, + CancellationToken cancellationToken) { var id = NextId(); _messages[id] = new Message(id, channelId, payload); @@ -82,7 +89,11 @@ public Task PostMessageAsync(ulong guildId, ulong channelId, MessagePaylo return Task.FromResult(id); } - public Task EditMessageAsync(ulong guildId, ulong channelId, ulong messageId, MessagePayload payload, CancellationToken cancellationToken) + public Task EditMessageAsync(ulong guildId, + ulong channelId, + ulong messageId, + MessagePayload payload, + CancellationToken cancellationToken) { _messages[messageId] = new Message(messageId, channelId, payload); EditedMessages++; @@ -102,4 +113,21 @@ public Task DeleteCategoryAsync(ulong guildId, ulong categoryId, CancellationTok } public IReadOnlyList GetMissingBotPermissions(ulong guildId) => MissingPermissions; + + private ulong NextId() => Interlocked.Increment(ref _nextId); + + public void ExternallyDeleteChannel(ulong channelId) => _channels.TryRemove(channelId, out _); + public void ExternallyDeleteCategory(ulong categoryId) => _categories.TryRemove(categoryId, out _); + public void ExternallyDeleteMessage(ulong messageId) => _messages.TryRemove(messageId, out _); + + public MessagePayload? GetMessagePayload(ulong messageId) => + _messages.TryGetValue(messageId, out var m) ? m.Payload : null; + + public ChannelPermissionProfile ProfileOf(ulong channelId) => _channels[channelId].Profile; + + private sealed record Category(ulong Id, string Name); + + private sealed record Channel(ulong Id, ulong CategoryId, string Name, ChannelPermissionProfile Profile); + + private sealed record Message(ulong Id, ulong ChannelId, MessagePayload Payload); } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGatewayTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGatewayTests.cs index 17f0e576..e161c2dd 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGatewayTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGatewayTests.cs @@ -13,7 +13,8 @@ public async Task CreateAndFind_RoundTrips() Assert.True(gateway.CategoryExists(1, categoryId)); Assert.Equal(categoryId, await gateway.FindCategoryAsync(1, "rustplusbot", default)); - var channelId = await gateway.CreateChannelAsync(1, categoryId, "information", ChannelPermissionProfile.ReadOnly, default); + var channelId = + await gateway.CreateChannelAsync(1, categoryId, "information", ChannelPermissionProfile.ReadOnly, default); Assert.Equal(channelId, await gateway.FindChannelAsync(1, categoryId, "information", default)); gateway.ExternallyDeleteChannel(channelId); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs index 5075d959..e2a6f8a4 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs @@ -7,19 +7,19 @@ namespace RustPlusBot.Features.Workspace.Tests.Fakes; /// In-memory for reconciler unit tests. internal sealed class FakeWorkspaceStore : IWorkspaceStore { - private static string Scope(ulong g, Guid? s) => $"{g}:{s?.ToString() ?? "global"}"; - private readonly ConcurrentDictionary _categories = new(); /// Key: scope|channelKey. private readonly ConcurrentDictionary _channels = new(); + private readonly ConcurrentDictionary _cultures = new(); + /// Key: scope|messageKey. private readonly ConcurrentDictionary _messages = new(); - private readonly ConcurrentDictionary _cultures = new(); - - public Task GetCategoryAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) => + public Task GetCategoryAsync(ulong guildId, + Guid? serverId, + CancellationToken cancellationToken = default) => Task.FromResult(_categories.GetValueOrDefault(Scope(guildId, serverId))); public Task SaveCategoryAsync(ProvisionedCategory category, CancellationToken cancellationToken = default) @@ -28,7 +28,9 @@ public Task SaveCategoryAsync(ProvisionedCategory category, CancellationToken ca return Task.CompletedTask; } - public Task> GetChannelsAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) + public Task> GetChannelsAsync(ulong guildId, + Guid? serverId, + CancellationToken cancellationToken = default) { var prefix = Scope(guildId, serverId) + "|"; IReadOnlyList list = _channels @@ -43,7 +45,10 @@ public Task SaveChannelAsync(ProvisionedChannel channel, CancellationToken cance return Task.CompletedTask; } - public Task GetMessageAsync(ulong guildId, Guid? serverId, string messageKey, CancellationToken cancellationToken = default) => + public Task GetMessageAsync(ulong guildId, + Guid? serverId, + string messageKey, + CancellationToken cancellationToken = default) => Task.FromResult(_messages.GetValueOrDefault($"{Scope(guildId, serverId)}|{messageKey}")); public Task SaveMessageAsync(ProvisionedMessage message, CancellationToken cancellationToken = default) @@ -78,7 +83,8 @@ public Task DeleteScopeAsync(ulong guildId, Guid? serverId, CancellationToken ca return Task.CompletedTask; } - public Task> GetAllCategoriesAsync(ulong guildId, CancellationToken cancellationToken = default) + public Task> GetAllCategoriesAsync(ulong guildId, + CancellationToken cancellationToken = default) { IReadOnlyList list = _categories.Values.Where(c => c.GuildId == guildId).ToList(); return Task.FromResult(list); @@ -89,4 +95,6 @@ public Task> GetProvisionedGuildIdsAsync(CancellationToken IReadOnlyList list = _categories.Values.Select(c => c.GuildId).Distinct().ToList(); return Task.FromResult(list); } + + private static string Scope(ulong g, Guid? s) => $"{g}:{s?.ToString() ?? "global"}"; } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index 5f7ebc0b..325626e7 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -18,7 +18,21 @@ public async Task Information_ShowsServerCount() { var servers = Substitute.For(); servers.ListAsync(1, Arg.Any()) - .Returns(new List { new() { Name = "A" }, new() { Name = "B" }, new() { Name = "C" } }); + .Returns(new List + { + new() + { + Name = "A" + }, + new() + { + Name = "B" + }, + new() + { + Name = "C" + } + }); var renderer = new InformationMessageRenderer(servers, Loc); var payload = await renderer.RenderAsync(Global, default); @@ -35,7 +49,8 @@ public async Task Settings_HasLanguageSelectMenu() var payload = await renderer.RenderAsync(Global, default); Assert.NotNull(payload.Components); - var selects = payload.Components!.Components.OfType().SelectMany(r => r.Components).OfType(); + var selects = payload.Components!.Components.OfType().SelectMany(r => r.Components) + .OfType(); Assert.Contains(selects, s => s.CustomId == "workspace:settings:culture"); } @@ -45,7 +60,14 @@ public async Task ServerInfo_TitleIsServerName_AndEndpointShown() var serverId = Guid.NewGuid(); var servers = Substitute.For(); servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "Rustopia EU", Ip = "1.2.3.4", Port = 28015 }); + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "Rustopia EU", + Ip = "1.2.3.4", + Port = 28015 + }); var renderer = new ServerInfoMessageRenderer(servers, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs index 1b1a1baf..5f2578dd 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs @@ -13,17 +13,18 @@ namespace RustPlusBot.Features.Workspace.Tests.Reconciler; /// Builds a WorkspaceReconciler wired with fakes for tests. internal sealed class ReconcilerHarness { - public FakeWorkspaceGateway Gateway { get; } = new(); - public FakeWorkspaceStore Store { get; } = new(); - public IServerService Servers { get; } = Substitute.For(); - private readonly List _channelProviders = []; private readonly List _messageProviders = []; private readonly List _renderers = []; + public FakeWorkspaceGateway Gateway { get; } = new(); + public FakeWorkspaceStore Store { get; } = new(); + public IServerService Servers { get; } = Substitute.For(); public ReconcilerHarness WithChannel(WorkspaceScope scope, string key, string nameKey, int order = 0) { - _channelProviders.Add(new StubChannelProvider([new ChannelSpec(scope, key, nameKey, ChannelPermissionProfile.ReadOnly, order)])); + _channelProviders.Add(new StubChannelProvider([ + new ChannelSpec(scope, key, nameKey, ChannelPermissionProfile.ReadOnly, order) + ])); return this; } @@ -56,7 +57,9 @@ private sealed class StubMessageProvider(IEnumerable specs) : IMess private sealed class StubRenderer(string key, string text) : IMessageRenderer { public string MessageKey { get; } = key; - public ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) => + + public ValueTask + RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) => ValueTask.FromResult(new MessagePayload(text, null, null)); } } @@ -69,7 +72,9 @@ internal sealed class ReconcilerBuilderReusing(ReconcilerHarness source) public ReconcilerBuilderReusing WithChannel(WorkspaceScope scope, string key, string nameKey, int order = 0) { - _channelProviders.Add(new ListChannelProvider([new ChannelSpec(scope, key, nameKey, ChannelPermissionProfile.ReadOnly, order)])); + _channelProviders.Add(new ListChannelProvider([ + new ChannelSpec(scope, key, nameKey, ChannelPermissionProfile.ReadOnly, order) + ])); return this; } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerAdoptTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerAdoptTests.cs index 61d476fa..300d0249 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerAdoptTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerAdoptTests.cs @@ -17,7 +17,8 @@ public async Task StoredChannelGone_ButSameNamedExists_RebindsNotRecreates() var channel = (await harness.Store.GetChannelsAsync(1, null))[0]; harness.Gateway.ExternallyDeleteChannel(channel.DiscordChannelId); var category = await harness.Store.GetCategoryAsync(1, null); - await harness.Gateway.CreateChannelAsync(1, category!.DiscordCategoryId, "information", ChannelPermissionProfile.ReadOnly, default); + await harness.Gateway.CreateChannelAsync(1, category!.DiscordCategoryId, "information", + ChannelPermissionProfile.ReadOnly, default); var createdBefore = harness.Gateway.CreatedChannels; await sut.ReconcileGlobalAsync(1); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs index 0949f5f6..9cab825c 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs @@ -15,7 +15,14 @@ public async Task HealGuild_HealsPerServerScope_EvenWithoutGlobal() var harness = new ReconcilerHarness() .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0); harness.Servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.1.1.1", Port = 1 }); + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "S", + Ip = "1.1.1.1", + Port = 1 + }); var sut = harness.Build(); await sut.ReconcileServerAsync(1, serverId); @@ -24,7 +31,8 @@ public async Task HealGuild_HealsPerServerScope_EvenWithoutGlobal() await sut.HealGuildAsync(1); - Assert.True(harness.Gateway.ChannelExists(1, (await harness.Store.GetChannelsAsync(1, serverId))[0].DiscordChannelId)); + Assert.True(harness.Gateway.ChannelExists(1, + (await harness.Store.GetChannelsAsync(1, serverId))[0].DiscordChannelId)); } [Fact] @@ -40,7 +48,8 @@ public async Task HealGuild_RecreatesDeletedChannel_WhenProvisioned() await sut.HealGuildAsync(1); - Assert.True(harness.Gateway.ChannelExists(1, (await harness.Store.GetChannelsAsync(1, null))[0].DiscordChannelId)); + Assert.True(harness.Gateway.ChannelExists(1, + (await harness.Store.GetChannelsAsync(1, null))[0].DiscordChannelId)); } [Fact] diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerServerTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerServerTests.cs index 790ba6f8..47b4de0b 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerServerTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerServerTests.cs @@ -15,7 +15,14 @@ public async Task ReconcileServer_CreatesCategoryNamedAfterServer_ScopedToServer .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0) .WithMessage(WorkspaceScope.PerServer, "server.info", "info", "info"); harness.Servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "Rustopia EU", Ip = "1.1.1.1", Port = 28015 }); + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "Rustopia EU", + Ip = "1.1.1.1", + Port = 28015 + }); var sut = harness.Build(); var result = await sut.ReconcileServerAsync(1, serverId); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs index 4e799379..591dd3aa 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs @@ -4,6 +4,18 @@ namespace RustPlusBot.Features.Workspace.Tests.Registry; public sealed class WorkspaceRegistryTests { + [Fact] + public void ChannelSpecs_AggregateAcrossProviders_OrderedByOrder() + { + var registry = new WorkspaceRegistry([new ProviderA(), new ProviderB()], []); + + var global = registry.GetChannelSpecs(WorkspaceScope.Global); + Assert.Equal(["a", "b"], global.Select(s => s.Key)); + + var perServer = registry.GetChannelSpecs(WorkspaceScope.PerServer); + Assert.Equal(["info"], perServer.Select(s => s.Key)); + } + private sealed class ProviderA : IChannelSpecProvider { public IEnumerable GetChannelSpecs() => @@ -20,16 +32,4 @@ public IEnumerable GetChannelSpecs() => new(WorkspaceScope.PerServer, "info", "info.name", ChannelPermissionProfile.ReadOnly, 0), ]; } - - [Fact] - public void ChannelSpecs_AggregateAcrossProviders_OrderedByOrder() - { - var registry = new WorkspaceRegistry([new ProviderA(), new ProviderB()], []); - - var global = registry.GetChannelSpecs(WorkspaceScope.Global); - Assert.Equal(["a", "b"], global.Select(s => s.Key)); - - var perServer = registry.GetChannelSpecs(WorkspaceScope.PerServer); - Assert.Equal(["info"], perServer.Select(s => s.Key)); - } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/WorkspaceTeardownServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/WorkspaceTeardownServiceTests.cs index 37bc33b9..609cd8c0 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/WorkspaceTeardownServiceTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/WorkspaceTeardownServiceTests.cs @@ -33,7 +33,14 @@ public async Task RemoveServer_DeletesOnlyThatServerScope() .WithChannel(WorkspaceScope.Global, "information", "channel.information.name", 0) .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0); harness.Servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.1.1.1", Port = 1 }); + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "S", + Ip = "1.1.1.1", + Port = 1 + }); var reconciler = harness.Build(); await reconciler.ReconcileGlobalAsync(1); await reconciler.ReconcileServerAsync(1, serverId); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs index 5b218810..de321767 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs @@ -22,7 +22,10 @@ public void ReconcilerAndTeardown_ResolveFromScope() services.AddBotPersistence("DataSource=:memory:"); services.AddWorkspace(); - using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); using var scope = provider.CreateScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs index 0c9fcf56..4f0fa0b8 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs @@ -13,7 +13,10 @@ public async Task RemovingServer_CascadeDeletesItsProvisioningRows() await using var _ = context; await using var __ = connection; - var server = new RustServer { GuildId = 1UL, Name = "S", Ip = "1.1.1.1", Port = 1 }; + var server = new RustServer + { + GuildId = 1UL, Name = "S", Ip = "1.1.1.1", Port = 1 + }; context.RustServers.Add(server); context.ProvisionedCategories.Add(new ProvisionedCategory { diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs index bc5de39e..74f8cd1b 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs @@ -6,11 +6,6 @@ namespace RustPlusBot.Persistence.Tests.Workspace; public sealed class WorkspaceStoreTests { - private sealed class FixedClock(DateTimeOffset now) : IClock - { - public DateTimeOffset UtcNow { get; } = now; - } - private static WorkspaceStore NewStore(out BotDbContext context, out IDisposable cleanup) { var (ctx, connection) = SqliteContextFixture.Create(); @@ -25,8 +20,14 @@ public async Task SaveCategory_IsUpsertByScope() var store = NewStore(out _, out var cleanup); using var _cleanup = cleanup; - await store.SaveCategoryAsync(new ProvisionedCategory { GuildId = 1, RustServerId = null, DiscordCategoryId = 10 }); - await store.SaveCategoryAsync(new ProvisionedCategory { GuildId = 1, RustServerId = null, DiscordCategoryId = 20 }); + await store.SaveCategoryAsync(new ProvisionedCategory + { + GuildId = 1, RustServerId = null, DiscordCategoryId = 10 + }); + await store.SaveCategoryAsync(new ProvisionedCategory + { + GuildId = 1, RustServerId = null, DiscordCategoryId = 20 + }); var loaded = await store.GetCategoryAsync(1, null); Assert.NotNull(loaded); @@ -39,9 +40,18 @@ public async Task SaveChannel_UpsertByKey_AndScopeIsolated() var store = NewStore(out _, out var cleanup); using var _cleanup = cleanup; - await store.SaveChannelAsync(new ProvisionedChannel { GuildId = 1, RustServerId = null, ChannelKey = "information", DiscordChannelId = 5 }); - await store.SaveChannelAsync(new ProvisionedChannel { GuildId = 1, RustServerId = null, ChannelKey = "information", DiscordChannelId = 6 }); - await store.SaveChannelAsync(new ProvisionedChannel { GuildId = 2, RustServerId = null, ChannelKey = "information", DiscordChannelId = 7 }); + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 1, RustServerId = null, ChannelKey = "information", DiscordChannelId = 5 + }); + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 1, RustServerId = null, ChannelKey = "information", DiscordChannelId = 6 + }); + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 2, RustServerId = null, ChannelKey = "information", DiscordChannelId = 7 + }); var g1 = await store.GetChannelsAsync(1, null); Assert.Single(g1); @@ -57,8 +67,14 @@ public async Task SaveMessage_UpsertByKey_SetsTimestamps() var store = NewStore(out _, out var cleanup); using var _cleanup = cleanup; - await store.SaveMessageAsync(new ProvisionedMessage { GuildId = 1, MessageKey = "information.main", DiscordChannelId = 5, DiscordMessageId = 100 }); - await store.SaveMessageAsync(new ProvisionedMessage { GuildId = 1, MessageKey = "information.main", DiscordChannelId = 5, DiscordMessageId = 101 }); + await store.SaveMessageAsync(new ProvisionedMessage + { + GuildId = 1, MessageKey = "information.main", DiscordChannelId = 5, DiscordMessageId = 100 + }); + await store.SaveMessageAsync(new ProvisionedMessage + { + GuildId = 1, MessageKey = "information.main", DiscordChannelId = 5, DiscordMessageId = 101 + }); var loaded = await store.GetMessageAsync(1, null, "information.main"); Assert.NotNull(loaded); @@ -84,13 +100,25 @@ public async Task DeleteScope_RemovesOnlyThatScope() using var _cleanup = cleanup; // RustServerId is a FK to RustServers, so we must insert a real server first. - var server = new RustPlusBot.Domain.Servers.RustServer { GuildId = 1, Name = "S", Ip = "1.1.1.1", Port = 1 }; + var server = new RustPlusBot.Domain.Servers.RustServer + { + GuildId = 1, Name = "S", Ip = "1.1.1.1", Port = 1 + }; context.RustServers.Add(server); await context.SaveChangesAsync(); - await store.SaveCategoryAsync(new ProvisionedCategory { GuildId = 1, RustServerId = null, DiscordCategoryId = 10 }); - await store.SaveChannelAsync(new ProvisionedChannel { GuildId = 1, RustServerId = null, ChannelKey = "information", DiscordChannelId = 5 }); - await store.SaveCategoryAsync(new ProvisionedCategory { GuildId = 1, RustServerId = server.Id, DiscordCategoryId = 11 }); + await store.SaveCategoryAsync(new ProvisionedCategory + { + GuildId = 1, RustServerId = null, DiscordCategoryId = 10 + }); + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 1, RustServerId = null, ChannelKey = "information", DiscordChannelId = 5 + }); + await store.SaveCategoryAsync(new ProvisionedCategory + { + GuildId = 1, RustServerId = server.Id, DiscordCategoryId = 11 + }); await store.DeleteScopeAsync(1, null); @@ -98,4 +126,9 @@ public async Task DeleteScope_RemovesOnlyThatScope() Assert.Empty(await store.GetChannelsAsync(1, null)); Assert.NotNull(await store.GetCategoryAsync(1, server.Id)); } + + private sealed class FixedClock(DateTimeOffset now) : IClock + { + public DateTimeOffset UtcNow { get; } = now; + } } From 4c25b010107b9fb5d73894e65114aaf00dfb3db6 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 14 Jun 2026 23:22:04 +0200 Subject: [PATCH 29/29] =?UTF-8?q?fix(workspace):=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20validate=20culture/port,=20clarify=20reset=20wor?= =?UTF-8?q?ding,=20Fran=C3=A7ais=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsComponentModule: only persist supported cultures (en/fr) from the (forgeable) component payload, defaulting to en, so an overlong/unknown value can't break GuildSettings.Culture (max 16). - WorkspaceAdminModule: validate simulate-server port is 1-65535; clarify /workspace reset description to 'this Discord server' to disambiguate from a Rust server. - SettingsMessageRenderer: correct the user-facing language label to 'Français'. --- .../Messages/SettingsMessageRenderer.cs | 2 +- .../Modules/SettingsComponentModule.cs | 5 ++++- .../Modules/WorkspaceAdminModule.cs | 8 +++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs index 38bf7c50..9d7cc349 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs @@ -29,7 +29,7 @@ public ValueTask RenderAsync(MessageRenderContext context, Cance .WithCustomId(LanguageSelectId) .WithPlaceholder(localizer.Get("settings.language.label", context.Culture)) .AddOption("English", "en") - .AddOption("Francais", "fr"); + .AddOption("Français", "fr"); var components = new ComponentBuilder().WithSelectMenu(menu).Build(); return ValueTask.FromResult(new MessagePayload(null, embed, components)); diff --git a/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs b/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs index 078a120c..f5c9123e 100644 --- a/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs +++ b/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs @@ -25,7 +25,10 @@ public async Task SetCultureAsync(string[] selectedValues) return; } - var culture = selectedValues.Length > 0 ? selectedValues[0] : "en"; + // Component payloads can be forged, so only persist a culture the bot actually supports + // (GuildSettings.Culture is capped at 16 chars and unknown values break rendering anyway). + var selected = selectedValues.Length > 0 ? selectedValues[0] : "en"; + var culture = selected is "en" or "fr" ? selected : "en"; await DeferAsync(ephemeral: true).ConfigureAwait(false); var scope = scopeFactory.CreateAsyncScope(); try diff --git a/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs b/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs index cf44bb14..dcabb609 100644 --- a/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs +++ b/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs @@ -24,7 +24,7 @@ public sealed class WorkspaceAdminModule( public const string ConfirmResetId = "workspace:reset:confirm"; /// Prompts to delete the entire provisioned workspace (dev-gated). - [SlashCommand("reset", "Delete ALL provisioned channels and records for this server (dangerous)")] + [SlashCommand("reset", "Delete ALL of the bot's channels and records in this Discord server (dangerous)")] public async Task ResetAsync() { if (!await EnsureEnabledAsync().ConfigureAwait(false)) @@ -75,6 +75,12 @@ public async Task SimulateServerAsync(string name, string ip, int port) return; } + if (port is < 1 or > 65535) + { + await RespondAsync("Port must be between 1 and 65535.", ephemeral: true).ConfigureAwait(false); + return; + } + await DeferAsync(ephemeral: true).ConfigureAwait(false); var scope = scopeFactory.CreateAsyncScope(); try