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/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.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..b1ec971b 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,11 @@ 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.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.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; }
+}
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/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs
new file mode 100644
index 00000000..ebbdb8e1
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs
@@ -0,0 +1,196 @@
+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);
+ }
+}
diff --git a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs
new file mode 100644
index 00000000..add747be
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs
@@ -0,0 +1,115 @@
+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/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/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs
new file mode 100644
index 00000000..e81c2712
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs
@@ -0,0 +1,130 @@
+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 void Dispose() => _cts.Dispose();
+
+ ///
+ 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.
+ }
+ }
+ }
+
+ 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)
+ {
+ // 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))
+ {
+ 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/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..5b9cecaa
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
@@ -0,0 +1,54 @@
+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..4ca7f3d1
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs
@@ -0,0 +1,60 @@
+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/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs
new file mode 100644
index 00000000..e44810ac
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs
@@ -0,0 +1,33 @@
+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..ef54731e
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs
@@ -0,0 +1,42 @@
+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..9d7cc349
--- /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("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/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/Modules/SettingsComponentModule.cs b/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs
new file mode 100644
index 00000000..f5c9123e
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs
@@ -0,0 +1,49 @@
+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;
+ }
+
+ // 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
+ {
+ 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/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);
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs b/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs
new file mode 100644
index 00000000..dcabb609
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs
@@ -0,0 +1,117 @@
+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 of the bot's channels and records in this Discord 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;
+ }
+
+ 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
+ {
+ 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;
+ }
+}
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/IWorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs
new file mode 100644
index 00000000..faa89bdb
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs
@@ -0,0 +1,26 @@
+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);
+
+ /// 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/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/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs b/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs
new file mode 100644
index 00000000..939b64e9
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs
@@ -0,0 +1,32 @@
+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,
+
+ /// Nothing was done because the target no longer exists (e.g. an unknown server).
+ Skipped = 2,
+}
+
+/// 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, []);
+
+ /// 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.
+ 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..c5f105d5
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs
@@ -0,0 +1,290 @@
+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);
+ }
+
+ await ReconcileGlobalCoreAsync(guildId, 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 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);
+
+ // 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;
+ }
+
+ if (gateway.GetMissingBotPermissions(guildId).Count > 0)
+ {
+ return;
+ }
+
+ if (categories.Any(c => c.RustServerId is null))
+ {
+ 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);
+ }
+ }
+
+ 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 false;
+ }
+
+ var culture = await store.GetCultureAsync(guildId, 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)
+ {
+ 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);
+
+ // 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
+ && 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 store.SaveMessageAsync(
+ 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);
+ await store.SaveMessageAsync(
+ new ProvisionedMessage
+ {
+ GuildId = guildId,
+ RustServerId = serverId,
+ MessageKey = spec.Key,
+ DiscordChannelId = channelId,
+ DiscordMessageId = messageId
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+}
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..9733af54
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs
@@ -0,0 +1,21 @@
+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/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/src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs
new file mode 100644
index 00000000..c6d05227
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs
@@ -0,0 +1,26 @@
+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..9863e8c4
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
@@ -0,0 +1,20 @@
+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/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..0fe5d9fe
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs
@@ -0,0 +1,52 @@
+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/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/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; }
+}
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
new file mode 100644
index 00000000..ff060634
--- /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));
+
+ services.AddHostedService();
+
+ return services;
+ }
+}
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
}
}
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..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;
@@ -29,15 +30,21 @@ 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();
/// 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,10 +54,12 @@ 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())
- .ApplyConfiguration(new EventSubscriptionConfiguration());
+ .ApplyConfiguration(new EventSubscriptionConfiguration())
+ .ApplyConfiguration(new ProvisionedCategoryConfiguration())
+ .ApplyConfiguration(new ProvisionedChannelConfiguration())
+ .ApplyConfiguration(new ProvisionedMessageConfiguration());
}
}
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/Configurations/ProvisionedCategoryConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs
new file mode 100644
index 00000000..3fb724ff
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs
@@ -0,0 +1,23 @@
+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..d4109549
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Configurations/ProvisionedChannelConfiguration.cs
@@ -0,0 +1,24 @@
+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..c6dfafba
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Configurations/ProvisionedMessageConfiguration.cs
@@ -0,0 +1,24 @@
+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