Skip to content

Commit 9fa5252

Browse files
HandyS11claude
andauthored
Subsystem 1a: Discord workspace provisioning & configuration (#4)
* chore(workspace): scaffold Features.Workspace project and tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(domain): add ProvisionedCategory/Channel/Message entities * refactor: remove /server, /bind, and ChannelBinding (superseded by workspace provisioning) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(persistence): provisioning schema + WorkspaceProvisioning migration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(persistence): IWorkspaceStore + WorkspaceStore upsert/teardown queries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): i18n localization seam with EN/FR catalog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): spec registry (channel/message specs, providers, renderer seam) * feat(workspace): IWorkspaceGateway seam + in-memory test fake Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): per-guild ProvisioningLock * feat(workspace): WorkspaceReconciler converge engine + fresh provision Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(workspace): address review — Skipped status + UpdatedAt on edit-in-place * test(workspace): reconciler is idempotent on re-run Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(workspace): reconciler adopts same-named channel on drift Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(workspace): reconciler self-heals a deleted channel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(workspace): message repost-vs-edit and orphan-spec retention Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(workspace): concurrent reconciles converge once Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(workspace): per-server category provisioning * feat(workspace): channel/message keys, renderers, and spec providers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): teardown service (RemoveServer / ResetGuild) * feat: ServerRegisteredEvent, WorkspaceOptions, and interaction-module assembly seam Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): Discord.Net implementation of IWorkspaceGateway Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): AddWorkspace DI registration + composition test Introduces WorkspaceServiceCollectionExtensions.AddWorkspace() wiring all workspace singletons and scoped services, validated by a ValidateScopes=true composition test that proves the full DI graph resolves without captive-dependency violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): /setup command module Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): settings language selector + /workspace reset & simulate-server * feat(workspace): HealGuild self-heal + hosted service (startup, ChannelDestroyed, ServerRegistered) * feat(host): wire AddWorkspace, config flag, and update running-locally docs * refactor(workspace): address final review — heal all provisioned scopes, skip empty payloads - HealGuildAsync now heals whichever scopes are provisioned (global-if-present + each provisioned server category) instead of gating on the global category, so a per-server-only guild (e.g. simulate-server without /setup) self-heals; still no-ops when nothing is provisioned, preserving reset resurrection-safety. - EnsureMessagesAsync skips posting an empty payload (a renderer whose source entity vanished mid-reconcile) since Discord rejects a message with no content/embed/components. - Document the in-process event bus no-replay window on the ServerRegistered consumer. * style(workspace): apply dotnet format (pre-push hook) * fix(workspace): address PR review — validate culture/port, clarify reset wording, Français label - SettingsComponentModule: only persist supported cultures (en/fr) from the (forgeable) component payload, defaulting to en, so an overlong/unknown value can't break GuildSettings.Culture (max 16). - WorkspaceAdminModule: validate simulate-server port is 1-65535; clarify /workspace reset description to 'this Discord server' to disambiguate from a Rust server. - SettingsMessageRenderer: correct the user-facing language label to 'Français'. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0f64db0 commit 9fa5252

92 files changed

Lines changed: 4000 additions & 375 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
<Project Path="src/RustPlusBot.Discord/RustPlusBot.Discord.csproj" />
55
<Project Path="src/RustPlusBot.Domain/RustPlusBot.Domain.csproj" />
66
<Project Path="src/RustPlusBot.Host/RustPlusBot.Host.csproj" />
7+
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
78
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
89
</Folder>
910
<Folder Name="/tests/">
1011
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />
12+
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
1113
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
1214
</Folder>
1315
</Solution>

docs/development/running-locally.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,18 @@
2121

2222
On first run the host applies the EF Core migration and creates `rustplusbot.db` in the working directory.
2323

24-
5. In the guild, use the slash commands:
25-
26-
- `/server add name:<name> ip:<host> port:<port>`
27-
- `/server list`
28-
- `/server remove id:<id>`
29-
- `/bind feature:<Chat|Events|Devices|Cameras> channel:<#channel>`
24+
5. In the guild, use the slash commands per the provisioning flow below.
3025

3126
A missing or empty `Discord:Token` makes the host fail fast at startup with a clear `OptionsValidationException`.
27+
28+
## Provisioning the workspace
29+
30+
1. Invite the bot with the **Manage Channels**, **Manage Roles**, **Manage Messages**, and
31+
**Embed Links** permissions.
32+
2. In your test guild, run `/setup`. The bot creates a **RustPlusBot** category with
33+
`#information`, `#setup`, and `#settings` channels and posts its anchored messages.
34+
3. Re-running `/setup` is safe — it reconciles and repairs without creating duplicates.
35+
4. Change the language from the selector in `#settings`.
36+
5. In Development (`Workspace:EnableDangerCommands = true`), use
37+
`/workspace simulate-server name:<n> ip:<host> port:<port>` to create a server category, and
38+
`/workspace reset` to delete the whole workspace.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>Published when a Rust server is registered to a guild (stub trigger in 1a; FCM pairing in 1b).</summary>
4+
/// <param name="GuildId">The owning guild snowflake.</param>
5+
/// <param name="ServerId">The registered server's id.</param>
6+
public sealed record ServerRegisteredEvent(ulong GuildId, Guid ServerId);

src/RustPlusBot.Discord/DiscordBotService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ namespace RustPlusBot.Discord;
1616
/// <param name="client">The Discord socket client.</param>
1717
/// <param name="interactions">The interaction service that dispatches slash commands.</param>
1818
/// <param name="services">The root service provider used to construct interaction modules.</param>
19+
/// <param name="moduleAssemblies">Additional assemblies to scan for interaction modules contributed by feature projects.</param>
1920
/// <param name="options">The Discord options carrying the bot token.</param>
2021
/// <param name="logger">The logger.</param>
2122
[SuppressMessage("Performance", "CA1873:Avoid potentially expensive logging",
@@ -25,6 +26,7 @@ public sealed class DiscordBotService(
2526
DiscordSocketClient client,
2627
InteractionService interactions,
2728
IServiceProvider services,
29+
IEnumerable<InteractionModuleAssembly> moduleAssemblies,
2830
IOptions<DiscordOptions> options,
2931
ILogger<DiscordBotService> logger) : IHostedService
3032
{
@@ -41,6 +43,11 @@ public async Task StartAsync(CancellationToken cancellationToken)
4143
client.JoinedGuild += OnJoinedGuildAsync;
4244

4345
await interactions.AddModulesAsync(Assembly.GetExecutingAssembly(), services).ConfigureAwait(false);
46+
foreach (var moduleAssembly in moduleAssemblies)
47+
{
48+
await interactions.AddModulesAsync(moduleAssembly.Assembly, services).ConfigureAwait(false);
49+
}
50+
4451
await client.LoginAsync(TokenType.Bot, _options.Token).ConfigureAwait(false);
4552
await client.StartAsync().ConfigureAwait(false);
4653
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
using System.Reflection;
2+
3+
namespace RustPlusBot.Discord;
4+
5+
/// <summary>Registers an assembly whose Discord interaction modules should be loaded at startup.</summary>
6+
/// <param name="Assembly">The assembly to scan for interaction modules.</param>
7+
public sealed record InteractionModuleAssembly(Assembly Assembly);

src/RustPlusBot.Discord/Modules/BindModule.cs

Lines changed: 0 additions & 42 deletions
This file was deleted.

src/RustPlusBot.Discord/Modules/ServerModule.cs

Lines changed: 0 additions & 119 deletions
This file was deleted.

src/RustPlusBot.Domain/Guilds/BoundFeature.cs

Lines changed: 0 additions & 17 deletions
This file was deleted.

src/RustPlusBot.Domain/Guilds/ChannelBinding.cs

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace RustPlusBot.Domain.Workspace;
2+
3+
/// <summary>A Discord category the bot has provisioned. One per scope (global or per-server).</summary>
4+
public sealed class ProvisionedCategory
5+
{
6+
/// <summary>Surrogate primary key.</summary>
7+
public Guid Id { get; set; } = Guid.NewGuid();
8+
9+
/// <summary>The owning Discord guild snowflake.</summary>
10+
public ulong GuildId { get; set; }
11+
12+
/// <summary>The server this category belongs to, or <c>null</c> for the global category.</summary>
13+
public Guid? RustServerId { get; set; }
14+
15+
/// <summary>The provisioned Discord category snowflake.</summary>
16+
public ulong DiscordCategoryId { get; set; }
17+
18+
/// <summary>When the record was first created (UTC).</summary>
19+
public DateTimeOffset CreatedAt { get; set; }
20+
}

0 commit comments

Comments
 (0)