Skip to content

Foundation (subsystem 0): bootable bot, persistence, event bus, /server + /bind - #1

Merged
HandyS11 merged 30 commits into
developfrom
feat/foundation
Jun 14, 2026
Merged

Foundation (subsystem 0): bootable bot, persistence, event bus, /server + /bind#1
HandyS11 merged 30 commits into
developfrom
feat/foundation

Conversation

@HandyS11

Copy link
Copy Markdown
Owner

Summary

Implements the foundation (subsystem 0) of RustPlusBot — a self-hosted, multi-guild Discord bot for the Rust+ companion, built on .NET 10, Discord.Net 3.20, and the prerelease RustPlusApi / Persistord libraries. This branch delivers a bootable, well-tested base; live Rust+ features are deferred to later subsystems.

What's included:

  • 5 projects with clean, acyclic layering: Abstractions (seams: IClock, IEventBus, ICredentialProtector, ICredentialStore), Domain (entities/enums), Persistence (BotDbContext on Persistord's DiscordDbContext + guild-scoped services + EF Core migration), Discord (gateway, hosted service, /server + /bind interaction modules), Host (Generic Host entrypoint).
  • Persistence: SQLite via EF Core 10; global ulonglong snowflake conversion inherited from Persistord; Rust-domain schema (servers, player credentials, connection state, guild settings, channel bindings, paired entities, event subscriptions); InitialCreate migration applied at startup.
  • Multi-tenancy: every Rust-domain row carries a GuildId; every query and command is guild-scoped (isolation enforced and tested).
  • Security: player tokens and FCM blobs are protected at rest (ASP.NET Data Protection) before persistence; the Discord token is read from user-secrets/config and validated at startup (fail-fast OptionsValidationException).
  • In-process event bus (System.Threading.Channels) decoupling future producers/consumers.
  • 16 tests covering the real invariants: guild isolation (servers + bindings), credential protection of both secret fields, snowflake round-trip incl. the GuildSettings PK, event delivery/type-isolation, and DI registration.
  • Build is 0 warnings / 0 errors under TreatWarningsAsErrors with NetAnalyzers/Roslynator/Sonar at latest-all.

Deferred to later subsystems (carry-forwards)

  • Live RustPlus connection, FCM pairing, credential-pool hot-swap + auto-failover (subsystem 1).
  • Chat bridge + !commands, map + live events, smart devices, cameras.
  • Global interaction error handling; FK/cascade strategy when a RustServer is removed; read-then-write upsert concurrency hardening.
  • RustPlusApi is not yet referenced anywhere (first wired in subsystem 1).

Test plan

  • dotnet build RustPlusBot.slnx → 0/0
  • dotnet test RustPlusBot.slnx → 16 passing
  • Boots: applies migration, creates rustplusbot.db, fails fast on missing token
  • Manual (needs a real token): set Discord:Token via user-secrets, invite the bot, run /server add|list|remove and /bind in a guild — see docs/development/running-locally.md

🤖 Generated with Claude Code

HandyS11 and others added 29 commits June 14, 2026 18:03
Central package management, Directory.Build.props analyzers, .editorconfig, git hooks, NuGet config, and the (empty) solution file for the RustPlusBot foundation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Discord.Net 3.20.0, Microsoft.EntityFrameworkCore.Sqlite/Design 10.0.9,
Microsoft.AspNetCore.DataProtection 10.0.9, and Persistord.Core 1.0.0-beta.1
to Directory.Packages.props and wire package references into each project.
Remove redundant ImplicitUsings/Nullable from project csproj files (already
set globally in Directory.Build.props). Add trailing newlines to test csproj files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces IClock and SystemClock in RustPlusBot.Abstractions.Time,
making time-dependent logic testable via dependency injection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the per-line pragma in SystemClockTests; interface references are intentional in tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces IEventBus contract and InMemoryEventBus implementation backed by
per-subscriber Channel<object> instances, enabling type-safe publish/subscribe
between producers (connection manager, FCM listener) and feature modules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Explicitly completes the per-subscriber channel in finally for prompt resource release, and documents the no-back-pressure tradeoff. From Task 4 code review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements BotDbContext (inheriting Persistord's DiscordDbContext for the
Discord skeleton and global ulong<->long snowflake conversion) plus five
internal IEntityTypeConfiguration classes for RustServer, PlayerCredential,
ChannelBinding, ConnectionState, and GuildSettings. Uses explicit
ApplyConfiguration calls (method-chained) to satisfy CA1812. Covered by a
TDD round-trip test over an in-memory SQLite connection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… credentials

Addresses Task 6 code-review findings:
- Add PairedEntityConfiguration with explicit HasMaxLength(128) on Name and
  a composite index on (GuildId, RustServerId), replacing convention-only mapping.
- Add EventSubscriptionConfiguration with explicit HasMaxLength(64) on EventKey
  and a composite index on (GuildId, RustServerId), replacing convention-only mapping.
- Register both configurations in BotDbContext.OnModelCreating.
- Mark ProtectedFcmCredentials as IsRequired() in PlayerCredentialConfiguration,
  consistent with ProtectedPlayerToken.
- Strengthen snowflake round-trip test: AddedByUserId uses ulong.MaxValue (maps to
  -1L, exercises high-bit path), and asserts Id != Guid.Empty and AddedByUserId
  round-trips correctly alongside the existing GuildId assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds DesignTimeDbContextFactory for dotnet-ef tooling, suppresses CS1591
in the Migrations editorconfig section, and generates the InitialCreate
migration covering all 12 tables (7 Rust-domain + 5 Persistord Discord
skeleton) with the correct unique index on ChannelBindings(GuildId,Feature).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements Task 8: ICredentialProtector contract in Abstractions, backed
by EphemeralDataProtectionProvider in Host; contract test in Persistence.Tests.
Adapted from DataProtectionProvider.Create() (absent in net10) to
EphemeralDataProtectionProvider, and added CA1515 suppression for Host
public types intentionally referenced from test projects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the broad CA1515 disable on the Host section of .editorconfig
with `internal sealed` + `InternalsVisibleTo` (the same pattern Worker
uses), keeping CA1515 active on the composition root. The test assembly
(RustPlusBot.Persistence.Tests) reaches the now-internal type via the
MSBuild InternalsVisibleTo item in RustPlusBot.Host.csproj — no
AssemblyInfo.cs file needed.

Also documents the CryptographicException on ICredentialProtector.Unprotect
so callers know decryption can fail when the payload is tampered with or
produced by a different key/purpose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements ICredentialStore and StoreCredentialRequest contracts in Abstractions,
and CredentialStore (EF-backed) in Persistence. Tokens are protected via
ICredentialProtector before write; credentials are stored as Standby.
Tests verify protection, persistence, and filtering by (guild, server).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the gap where ProtectedFcmCredentials was unverified; adds Received() call-count checks on the protector. From Task 9 code review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements IServerService and ServerService (Add/List/Remove) with TDD.
Three tests verify guild scoping: persistence, cross-guild isolation, and remove guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements IBindingService and BindingService with upsert logic (bind
or re-bind a feature to a channel per guild), backed by the existing
ChannelBindings DbSet and its UNIQUE(GuildId, Feature) index.
Two passing tests cover initial bind and rebind scenarios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds explicit cross-guild isolation coverage matching the ServerService test bar. From Task 11 code review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Descriptor-presence check (it can't be resolved in isolation without the Host-supplied ICredentialProtector). From Task 12 code review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds DiscordOptions (bound from "Discord" config section), DiscordBotService
(IHostedService that logs in, loads interaction modules, and registers slash
commands per guild), and DiscordServiceCollectionExtensions.AddDiscordBot().
Also adds Microsoft.Extensions.Hosting.Abstractions 10.0.9 and suppresses
CA1848/CA1873 globally in .editorconfig for this bot's low-volume logging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(1) The Discord gateway fires the Ready event on every successful (re)connect,
so OnReadyAsync previously bulk-overwrote all guild slash commands on each
reconnect — each call counts against Discord's per-app daily command-write
limit. Added a private _hasRegisteredCommands flag (set to true only after a
successful registration pass, so transient failures retry on the next Ready)
to ensure registration happens exactly once per process lifetime. No locking
or volatile is needed because Ready is dispatched serially on the gateway
thread.

(2) CA1873 was suppressed globally in .editorconfig, which would silently
hide genuinely expensive log-argument evaluation in future code elsewhere in
the solution. Removed the global suppression and replaced it with a
type-level [SuppressMessage] on DiscordBotService, co-located with the code
where the log-bridge arguments (LogMessage property reads) are confirmed cheap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the Task-1 Worker stub with the full Generic Host wiring:
registers DataProtection, IClock, IEventBus, ICredentialProtector,
persistence (AddBotPersistence), and the Discord hosted service
(AddDiscordBot). Runs EF Core migrations in a scoped pre-start block
before the host loop begins. All awaits carry ConfigureAwait(false)
per CA2007. appsettings.json extended with Discord and Database sections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces Configure<DiscordOptions> with a validated AddOptions+ValidateOnStart so a missing token throws a clear OptionsValidationException at boot instead of a runtime Discord 401. From Task 15 code review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GuildSettings.GuildId is a Discord guild snowflake that serves as the
primary key. Without ValueGeneratedNever(), EF Core's default convention
treats the long-mapped integer PK as store-generated (Sqlite:Autoincrement),
which causes SQLite to replace any supplied guild id with an autoincrement
rowid — silently corrupting the guild key on insert.

Added ValueGeneratedNever() to GuildSettingsConfiguration to match the
pattern already used by all Persistord snowflake-PK entities. Regenerated
the InitialCreate migration so the schema no longer carries the Autoincrement
annotation and the model snapshot no longer shows ValueGeneratedOnAdd for
this property. Added a round-trip regression test that inserts a large
real snowflake guild id and asserts it survives SaveChanges unchanged.

From final code review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Line-wrapping and initializer layout from the .githooks/pre-push ReformatAndReorder profile; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 14, 2026 18:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements the “foundation” layer of RustPlusBot by introducing a bootable .NET host, a Discord gateway with initial slash commands, and an EF Core (SQLite) persistence layer with migrations and tests.

Changes:

  • Added persistence subsystem: BotDbContext, entity configurations, SQLite migrations, and guild-scoped services (servers, bindings, credentials).
  • Added Discord subsystem: hosted gateway service plus /server add|list|remove and /bind interaction modules.
  • Added abstractions + tests + repo tooling/config to support development and enforce analyzers/formatting.

Reviewed changes

Copilot reviewed 73 out of 75 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs Test fixture for in-memory SQLite BotDbContext instances.
tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs Tests for guild-scoped server CRUD behavior.
tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj Persistence test project definition and dependencies.
tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs DI registration test for persistence layer services.
tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs Tests for credential protection + persistence invariants.
tests/RustPlusBot.Persistence.Tests/Credentials/CredentialProtectorContractTests.cs Contract test for protect/unprotect round-trip.
tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs EF Core mapping tests (snowflake PK + round-trip).
tests/RustPlusBot.Persistence.Tests/Bindings/BindingServiceTests.cs Tests for guild-scoped channel binding behavior.
tests/RustPlusBot.Abstractions.Tests/Time/SystemClockTests.cs Test for IClock/SystemClock behavior.
tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj Abstractions test project definition.
tests/RustPlusBot.Abstractions.Tests/Events/InMemoryEventBusTests.cs Tests for in-process event bus delivery and isolation.
src/RustPlusBot.Persistence/Servers/ServerService.cs EF-backed guild-scoped server management service.
src/RustPlusBot.Persistence/Servers/IServerService.cs Contract for server management.
src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj Persistence project definition and package refs.
src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs DI wiring for persistence layer.
src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs EF Core model snapshot for migrations.
src/RustPlusBot.Persistence/Migrations/20260614180746_InitialCreate.Designer.cs Migration designer for initial schema.
src/RustPlusBot.Persistence/Migrations/20260614180746_InitialCreate.cs Initial EF Core migration (schema creation).
src/RustPlusBot.Persistence/DesignTimeDbContextFactory.cs dotnet-ef design-time context factory.
src/RustPlusBot.Persistence/Credentials/CredentialStore.cs EF-backed ICredentialStore that protects secrets before write.
src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs EF entity configuration for RustServer.
src/RustPlusBot.Persistence/Configurations/PlayerCredentialConfiguration.cs EF entity configuration for PlayerCredential.
src/RustPlusBot.Persistence/Configurations/PairedEntityConfiguration.cs EF entity configuration for PairedEntity.
src/RustPlusBot.Persistence/Configurations/GuildSettingsConfiguration.cs EF entity configuration for GuildSettings PK strategy.
src/RustPlusBot.Persistence/Configurations/EventSubscriptionConfiguration.cs EF entity configuration for EventSubscription.
src/RustPlusBot.Persistence/Configurations/ConnectionStateConfiguration.cs EF entity configuration for ConnectionState.
src/RustPlusBot.Persistence/Configurations/ChannelBindingConfiguration.cs EF entity configuration for unique (GuildId, Feature) bindings.
src/RustPlusBot.Persistence/BotDbContext.cs EF Core context combining Persistord skeleton + Rust domain sets.
src/RustPlusBot.Persistence/Bindings/IBindingService.cs Contract for channel binding operations.
src/RustPlusBot.Persistence/Bindings/BindingService.cs EF-backed guild-scoped binding service.
src/RustPlusBot.Host/RustPlusBot.Host.csproj Generic Host entrypoint project + InternalsVisibleTo for tests.
src/RustPlusBot.Host/Properties/launchSettings.json Local dev launch profile/environment.
src/RustPlusBot.Host/Program.cs Host wiring: options validation, DI setup, migration at startup.
src/RustPlusBot.Host/Credentials/DataProtectionCredentialProtector.cs Data Protection-backed credential protector implementation.
src/RustPlusBot.Host/appsettings.json Default configuration scaffold (token empty, SQLite file DB).
src/RustPlusBot.Domain/Servers/RustServer.cs Domain entity for guild-scoped Rust server targets.
src/RustPlusBot.Domain/RustPlusBot.Domain.csproj Domain project definition.
src/RustPlusBot.Domain/Guilds/GuildSettings.cs Domain entity for per-guild settings keyed by snowflake.
src/RustPlusBot.Domain/Guilds/ChannelBinding.cs Domain entity for channel-to-feature binding.
src/RustPlusBot.Domain/Guilds/BoundFeature.cs Enum for bindable bot features.
src/RustPlusBot.Domain/Events/EventSubscription.cs Domain entity for per-guild event subscriptions.
src/RustPlusBot.Domain/Entities/PairedEntityKind.cs Enum for paired device kinds.
src/RustPlusBot.Domain/Entities/PairedEntity.cs Domain entity for paired devices.
src/RustPlusBot.Domain/Credentials/PlayerCredential.cs Domain entity for protected Rust+ credentials.
src/RustPlusBot.Domain/Credentials/CredentialStatus.cs Enum for credential lifecycle state.
src/RustPlusBot.Domain/Connections/ConnectionState.cs Domain entity for persisted connection state.
src/RustPlusBot.Discord/RustPlusBot.Discord.csproj Discord layer project definition and dependencies.
src/RustPlusBot.Discord/Modules/ServerModule.cs /server command module (add/list/remove).
src/RustPlusBot.Discord/Modules/BindModule.cs /bind command module.
src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs DI wiring for Discord socket client + hosted service.
src/RustPlusBot.Discord/DiscordOptions.cs Options type for Discord token binding.
src/RustPlusBot.Discord/DiscordBotService.cs Hosted Discord gateway + interaction module registration/dispatch.
src/RustPlusBot.Abstractions/Time/SystemClock.cs IClock wall-clock implementation.
src/RustPlusBot.Abstractions/Time/IClock.cs Clock abstraction contract.
src/RustPlusBot.Abstractions/RustPlusBot.Abstractions.csproj Abstractions project definition.
src/RustPlusBot.Abstractions/Events/InMemoryEventBus.cs Channel-backed in-process event bus implementation.
src/RustPlusBot.Abstractions/Events/IEventBus.cs Event bus abstraction contract.
src/RustPlusBot.Abstractions/Credentials/StoreCredentialRequest.cs Request DTO for credential storage.
src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs Credential store abstraction contract.
src/RustPlusBot.Abstractions/Credentials/ICredentialProtector.cs Secret protection abstraction contract.
RustPlusBot.slnx.DotSettings JetBrains cleanup profile settings for formatting/reordering.
RustPlusBot.slnx Solution file including src + tests projects.
README.md Project overview, stack, build/run instructions, roadmap.
NuGet.config NuGet feed configuration.
docs/development/running-locally.md Local run instructions using user-secrets and slash commands.
Directory.Packages.props Central package version management.
Directory.Build.props Repo-wide build/analyzer settings + git hooks bootstrap target.
.vscode/settings.json VS Code .NET background analysis configuration.
.vscode/extensions.json VS Code recommended extensions list.
.markdownlint.json Markdownlint configuration.
.gitignore Git ignore rules (including SQLite DB artifacts).
.github/dependabot.yml Dependabot NuGet update configuration.
.githooks/pre-push Pre-push hook running ReSharper cleanup profile on pushed files.
.editorconfig Repo-wide code style + analyzer configuration.
.config/dotnet-tools.json Local tool manifest (jb, ef, docfx, stryker).
Files not reviewed (1)
  • src/RustPlusBot.Persistence/Migrations/20260614180746_InitialCreate.Designer.cs: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. urn:shemas-jetbrains-com:settings-storage-xaml is the actual JetBrains settings-storage namespace URN — a long-standing upstream typo that is part of the .DotSettings format itself, not an error in this repo. Every JetBrains-generated .DotSettings uses this exact spelling (e.g. the sibling RustPlusApi and Persistord repos), and the pre-push ReSharper cleanup (jb cleanupcode via .githooks/pre-push) loads this file and runs successfully on every push. Renaming it to schemas would actually break Rider/ReSharper loading the cleanup profile.

Comment on lines +19 to +21
var context = new BotDbContext(options);
context.Database.EnsureCreated();
return (context, connection);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 16a20af. The fixture now calls Database.Migrate() instead of EnsureCreated(), so tests exercise the committed migration — the same schema path the Host applies at startup — and will catch migration/model drift. All 16 tests still pass.

Comment on lines +18 to +20
{
var server = new RustServer
{

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 16a20af with a 1-65535 range check. I put it in the /server add command handler (returns a clear ephemeral "Port must be between 1 and 65535.") rather than throwing from the persistence service: there is no global interaction error handler yet (deferred to a later subsystem), so a throw at the persistence boundary would surface to the user as a bare "interaction failed". The command layer is where user-facing input validation belongs here.

Addresses PR review: the SQLite test fixture now uses Database.Migrate() instead of EnsureCreated() so tests exercise the committed migration; /server add rejects ports outside 1-65535 with a clear ephemeral message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@HandyS11
HandyS11 merged commit 0f64db0 into develop Jun 14, 2026
1 check passed
@HandyS11
HandyS11 deleted the feat/foundation branch June 14, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants