diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx index fea52606..032a6480 100644 --- a/RustPlusBot.slnx +++ b/RustPlusBot.slnx @@ -16,6 +16,7 @@ + @@ -34,6 +35,7 @@ + diff --git a/docs/superpowers/plans/2026-07-15-wipe-detection.md b/docs/superpowers/plans/2026-07-15-wipe-detection.md new file mode 100644 index 00000000..46482734 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-wipe-detection.md @@ -0,0 +1,2985 @@ +# Server Wipe Detection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Detect a Rust server wipe on reconnect (persisted WipeTime/Seed/WorldSize baseline), announce it in the per-server #events channel (optionally pinging @everyone via a new global setting, default off), and auto-purge all paired smart devices (rows + Discord embeds) so no stale devices or alarm notifications survive the wipe. + +**Architecture:** A new `RustPlusBot.Features.Wipes` feature project subscribes to the existing `ConnectionStatusChangedEvent`; on each transition to connected it reads `IRustServerQuery.GetServerInfoAsync`/`GetWorldAsync`, diffs against a baseline persisted on `RustServer` (new columns), and publishes a new `ServerWipedEvent` on the in-process bus. Alarms/Switches/StorageMonitors each subscribe and purge their own rows and channel messages. A `PingEveryoneOnWipe` flag on `GuildSettings` is surfaced as a toggle button in the #settings message. + +**Tech Stack:** C# / .NET 10, EF Core 10 (SQLite), Discord.Net, xUnit + NSubstitute, resx localization (en/fr). + +**Spec:** `docs/superpowers/specs/2026-07-15-wipe-detection-design.md` + +**Spec deviation (agreed):** the spec's "Features.Pairing purges `PairedEntity` rows" step is DROPPED — nothing in production code ever inserts `PairedEntity` rows (verified: the only writer is a test; the only reader is `GuildPurgeService`), so a per-server wipe purge of that table would be dead code. + +## Global Constraints + +- Solution file is `RustPlusBot.slnx` (no `.sln`). Build with `dotnet build RustPlusBot.slnx`. +- **`-maxcpucount:1` is MANDATORY on EVERY `dotnet build` AND `dotnet test`.** A `ConfigureGitHooks BeforeTargets="Build"` target races on `.git/config` under parallel builds; a failed build silently DROPS an assembly's tests (they report 0 and look "passing"). Always read the per-assembly test counts, never just "passed". +- Run `dotnet tool restore` once before the first build (jb / ef / stryker / docfx are local manifest tools). +- Build is `-warnaserror` (`TreatWarningsAsErrors=true` in `Directory.Build.props`): every public/internal type and member needs XML `///` docs; `CA1305/CA1307/CA1310` → pass `CultureInfo.InvariantCulture` / `StringComparison.Ordinal`; `CA2007` → `.ConfigureAwait(false)` on every awaited task in `src/` (tests don't need it). +- Tests are plain xUnit `Assert.*` + NSubstitute. NO FluentAssertions. `using Xunit` is a global using — do not add it per-file. +- `dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx` is a hard CI gate that fails on any diff — run it before every commit (local tool, `dotnet jb`). It is slow; running it once per task right before `git commit` is fine. +- EF migrations use the local tool: `dotnet ef` (dotnet-ef 10.0.9). Migrations live in `src/RustPlusBot.Persistence/Migrations`; `--project src/RustPlusBot.Persistence --startup-project src/RustPlusBot.Host`. If `dotnet ef` errors with "Unable to retrieve project metadata", build the solution first and retry. +- Event-bus consumer loops follow the exact `AlarmsHostedService` shape: one `Task.Run` loop per event type, `await foreach (… in eventBus.SubscribeAsync(ct))`, `OperationCanceledException` swallowed, broad `catch (Exception)` with `#pragma warning disable CA1031` + a `[LoggerMessage]` log. +- The localization parity test (`tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs`) fails if `Strings.resx` and `Strings.fr.resx` key sets differ — always add new keys to BOTH. +- Commit messages are plain imperative sentences (repo style, no `feat:` prefixes), each ending with the trailer line `Co-Authored-By: Claude Fable 5 `. +- `docs/superpowers/**` is tracked in git (NOT ignored) — commit the spec/plan normally. +- Feature branch: `feat/wipe-detection` off `develop`. Create it before Task 1: `git checkout -b feat/wipe-detection`. + +--- + +### Task 1: Wipe baseline columns + wipe-ping guild setting (Domain + migration) + +Add the persisted state everything else diffs against: three nullable baseline columns on `RustServer` and a `PingEveryoneOnWipe` bool on `GuildSettings`, in one EF migration. + +**Files:** +- Modify: `src/RustPlusBot.Domain/Servers/RustServer.cs` +- Modify: `src/RustPlusBot.Domain/Guilds/GuildSettings.cs` +- Modify: `src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs` +- Create (generated): `src/RustPlusBot.Persistence/Migrations/*_WipeDetection.cs` (+ `.Designer.cs`, snapshot update) +- Test: `tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs` (create) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `RustServer.LastWipeTimeUtc` (`DateTimeOffset?`), `RustServer.LastMapSeed` (`uint?`), `RustServer.LastMapSize` (`uint?`), `GuildSettings.PingEveryoneOnWipe` (`bool`, default `false`). Task 2's store reads/writes these. + +- [ ] **Step 1: Write the failing round-trip test** + +Create `tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs` (the `Servers/` test folder already exists; `SqliteContextFixture` applies the real migrations, so this fails until the migration exists): + +```csharp +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Domain.Guilds; +using RustPlusBot.Domain.Servers; + +namespace RustPlusBot.Persistence.Tests.Servers; + +/// Round-trips the wipe-baseline columns and the wipe-ping guild flag through the migrated schema. +public sealed class RustServerWipeColumnsTests +{ + [Fact] + public async Task Wipe_baseline_columns_round_trip() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + + var server = new RustServer + { + GuildId = 10UL, + Name = "S", + Ip = "1.1.1.1", + Port = 28015, + LastWipeTimeUtc = new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), + LastMapSeed = 123456u, + LastMapSize = 4250u, + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var loaded = await context.RustServers.FindAsync(server.Id); + + Assert.NotNull(loaded); + Assert.Equal(new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), loaded.LastWipeTimeUtc); + Assert.Equal(123456u, loaded.LastMapSeed); + Assert.Equal(4250u, loaded.LastMapSize); + } + + [Fact] + public async Task New_server_has_empty_baseline_and_guild_ping_defaults_false() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + context.GuildSettings.Add(new GuildSettings + { + GuildId = 10UL + }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var loadedServer = await context.RustServers.FindAsync(server.Id); + var loadedGuild = await context.GuildSettings.SingleOrDefaultAsync(g => g.GuildId == 10UL); + + Assert.NotNull(loadedServer); + Assert.Null(loadedServer.LastWipeTimeUtc); + Assert.Null(loadedServer.LastMapSeed); + Assert.Null(loadedServer.LastMapSize); + Assert.NotNull(loadedGuild); + Assert.False(loadedGuild.PingEveryoneOnWipe); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj -maxcpucount:1 --filter RustServerWipeColumnsTests` +Expected: FAIL — compile error (`LastWipeTimeUtc` does not exist). + +- [ ] **Step 3: Add the domain properties** + +In `src/RustPlusBot.Domain/Servers/RustServer.cs`, append inside the class (after `FacepunchServerId`): + +```csharp + /// Baseline: the last observed wipe time (UTC) from getInfo, or null before first observation. + public DateTimeOffset? LastWipeTimeUtc { get; set; } + + /// Baseline: the last observed procedural map seed, or null before first observation. + public uint? LastMapSeed { get; set; } + + /// Baseline: the last observed world size (game units), or null before first observation. + public uint? LastMapSize { get; set; } +``` + +In `src/RustPlusBot.Domain/Guilds/GuildSettings.cs`, append inside the class: + +```csharp + /// When true, the server-wiped announcement pings @everyone in #events. Off by default. + public bool PingEveryoneOnWipe { get; set; } +``` + +In `src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs`, add explicit `long` conversions for the `uint?` columns (keeps the model portable to providers without native uint support), after the existing `Property` lines: + +```csharp + builder.Property(s => s.LastMapSeed).HasConversion(); + builder.Property(s => s.LastMapSize).HasConversion(); +``` + +- [ ] **Step 4: Generate the migration** + +Run (from repo root): + +```bash +dotnet build RustPlusBot.slnx -maxcpucount:1 +dotnet ef migrations add WipeDetection --project src/RustPlusBot.Persistence --startup-project src/RustPlusBot.Host +``` + +Expected: a new `Migrations/_WipeDetection.cs` whose `Up` adds nullable `LastWipeTimeUtc` (TEXT), `LastMapSeed` (INTEGER), `LastMapSize` (INTEGER) to `RustServers`, and non-nullable `PingEveryoneOnWipe` (INTEGER, `defaultValue: false`) to `GuildSettings`. Inspect the file to confirm exactly these four columns and nothing else. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `dotnet test tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj -maxcpucount:1` +Expected: PASS, including the 2 new tests (check per-assembly counts). + +Also verify no model drift: `dotnet ef migrations has-pending-model-changes --project src/RustPlusBot.Persistence --startup-project src/RustPlusBot.Host` +Expected: "No changes have been made to the model since the last migration." + +- [ ] **Step 6: Cleanup + commit** + +```bash +dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx +git add -A +git commit -m "Add wipe baseline columns and wipe-ping guild setting + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: WipeBaselineStore + wipe-ping accessors on IWorkspaceStore + +Persistence seams the detector and announcer will use: a single-purpose baseline store over the new `RustServer` columns, and `Get/SetPingEveryoneOnWipeAsync` beside the existing culture accessors. + +**Files:** +- Create: `src/RustPlusBot.Persistence/Wipes/WipeBaseline.cs` +- Create: `src/RustPlusBot.Persistence/Wipes/IWipeBaselineStore.cs` +- Create: `src/RustPlusBot.Persistence/Wipes/WipeBaselineStore.cs` +- Modify: `src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs` (two methods, next to `GetCultureAsync`/`SetCultureAsync`) +- Modify: `src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs` +- Modify: `src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs` (register the store) +- Test: `tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs` (create) +- Test: `tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs` (create) + +**Interfaces:** +- Consumes: Task 1's columns. +- Produces (used by Tasks 3 and 5): + +```csharp +public sealed record WipeBaseline(DateTimeOffset? WipeTimeUtc, uint? MapSeed, uint? MapSize); + +public interface IWipeBaselineStore +{ + Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + Task SetAsync(ulong guildId, Guid serverId, WipeBaseline baseline, CancellationToken cancellationToken = default); +} + +// added to IWorkspaceStore: +Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationToken cancellationToken = default); +Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, CancellationToken cancellationToken = default); +``` + +`GetAsync` returns **null when the server row does not exist** (server removed mid-check) and a `WipeBaseline` with all-null members for a never-observed server — callers distinguish the two. + +- [ ] **Step 1: Write the failing store tests** + +Create `tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs`: + +```csharp +using Microsoft.Data.Sqlite; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Persistence.Wipes; + +namespace RustPlusBot.Persistence.Tests.Wipes; + +/// Unit tests for . +public sealed class WipeBaselineStoreTests +{ + private static (WipeBaselineStore Store, BotDbContext Context, SqliteConnection Conn) Create() + { + var (context, connection) = SqliteContextFixture.Create(); + return (new WipeBaselineStore(context), context, connection); + } + + private static async Task SeedServerAsync(BotDbContext context) + { + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + return server.Id; + } + + [Fact] + public async Task Get_returns_null_for_unknown_server() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + + var baseline = await store.GetAsync(10UL, Guid.NewGuid()); + + Assert.Null(baseline); + } + + [Fact] + public async Task Get_returns_empty_baseline_for_new_server() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + var baseline = await store.GetAsync(10UL, serverId); + + Assert.Equal(new WipeBaseline(null, null, null), baseline); + } + + [Fact] + public async Task Set_then_Get_round_trips() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + var baseline = new WipeBaseline(new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), 42u, 3500u); + + await store.SetAsync(10UL, serverId, baseline); + + Assert.Equal(baseline, await store.GetAsync(10UL, serverId)); + } + + [Fact] + public async Task Set_is_noop_for_unknown_server() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + + await store.SetAsync(10UL, Guid.NewGuid(), new WipeBaseline(null, 42u, null)); + + // No throw; nothing persisted. + Assert.Null(await store.GetAsync(10UL, Guid.NewGuid())); + } + + [Fact] + public async Task Get_is_guild_scoped() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + var baseline = await store.GetAsync(999UL, serverId); + + Assert.Null(baseline); + } +} +``` + +Create `tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs` (mirror the `WorkspaceStore` construction used by the existing tests in that folder — it takes the context; check the neighboring test file's `Create` helper and copy its constructor arguments exactly if it differs): + +```csharp +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Persistence.Tests.Workspace; + +/// Wipe-ping accessor tests for . +public sealed class WorkspaceStoreWipePingTests +{ + [Fact] + public async Task Ping_defaults_false_without_settings_row() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + var store = new WorkspaceStore(context); + + Assert.False(await store.GetPingEveryoneOnWipeAsync(10UL)); + } + + [Fact] + public async Task Set_true_then_get_round_trips_and_upserts_row() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + var store = new WorkspaceStore(context); + + await store.SetPingEveryoneOnWipeAsync(10UL, enabled: true); + + Assert.True(await store.GetPingEveryoneOnWipeAsync(10UL)); + // The upserted row keeps the default culture. + Assert.Equal("en", await store.GetCultureAsync(10UL)); + } + + [Fact] + public async Task Set_preserves_existing_culture() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + var store = new WorkspaceStore(context); + await store.SetCultureAsync(10UL, "fr"); + + await store.SetPingEveryoneOnWipeAsync(10UL, enabled: true); + + Assert.Equal("fr", await store.GetCultureAsync(10UL)); + Assert.True(await store.GetPingEveryoneOnWipeAsync(10UL)); + } +} +``` + +> If `WorkspaceStore`'s constructor takes more than the context, copy the construction from the existing `Workspace/` tests. + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj -maxcpucount:1 --filter "WipeBaselineStoreTests|WorkspaceStoreWipePingTests"` +Expected: FAIL — compile errors (types/methods missing). + +- [ ] **Step 3: Implement** + +Create `src/RustPlusBot.Persistence/Wipes/WipeBaseline.cs`: + +```csharp +namespace RustPlusBot.Persistence.Wipes; + +/// The persisted wipe baseline for a server: the last observed wipe time and world identity. All-null before the first observation. +/// The last observed wipe time (UTC), or null when the server never reported one. +/// The last observed procedural map seed, or null before first observation. +/// The last observed world size (game units), or null before first observation. +public sealed record WipeBaseline(DateTimeOffset? WipeTimeUtc, uint? MapSeed, uint? MapSize); +``` + +Create `src/RustPlusBot.Persistence/Wipes/IWipeBaselineStore.cs`: + +```csharp +namespace RustPlusBot.Persistence.Wipes; + +/// Reads/writes the per-server wipe baseline stored on the RustServer row. +public interface IWipeBaselineStore +{ + /// Gets the baseline, or null when the server row does not exist. + /// The owning guild snowflake. + /// The server id. + /// A cancellation token. + /// The baseline (all-null members before first observation), or null for an unknown server. + Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + + /// Overwrites the baseline (no-op when the server row does not exist). + /// The owning guild snowflake. + /// The server id. + /// The new baseline values. + /// A cancellation token. + /// A task that completes when the baseline has been persisted. + Task SetAsync(ulong guildId, Guid serverId, WipeBaseline baseline, CancellationToken cancellationToken = default); +} +``` + +Create `src/RustPlusBot.Persistence/Wipes/WipeBaselineStore.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; + +namespace RustPlusBot.Persistence.Wipes; + +/// Default over the RustServer baseline columns. +/// The bot database context. +internal sealed class WipeBaselineStore(BotDbContext context) : IWipeBaselineStore +{ + /// + public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) + { + var server = await context.RustServers + .SingleOrDefaultAsync(s => s.GuildId == guildId && s.Id == serverId, cancellationToken) + .ConfigureAwait(false); + return server is null + ? null + : new WipeBaseline(server.LastWipeTimeUtc, server.LastMapSeed, server.LastMapSize); + } + + /// + public async Task SetAsync(ulong guildId, Guid serverId, WipeBaseline baseline, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseline); + var server = await context.RustServers + .SingleOrDefaultAsync(s => s.GuildId == guildId && s.Id == serverId, cancellationToken) + .ConfigureAwait(false); + if (server is null) + { + return; + } + + server.LastWipeTimeUtc = baseline.WipeTimeUtc; + server.LastMapSeed = baseline.MapSeed; + server.LastMapSize = baseline.MapSize; + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} +``` + +> If the compiler rejects `internal` here because `Persistence.Tests` can't see it, check `src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj` for an `InternalsVisibleTo` — `AlarmStore` is constructed directly by its tests, so the mechanism already exists; mirror `AlarmStore`'s access modifiers exactly. + +In `src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs`, add right after `SetCultureAsync`: + +```csharp + /// True when the guild wants the server-wiped announcement to ping @everyone. Defaults to false. + /// The guild snowflake. + /// A cancellation token. + /// The current flag value. + Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationToken cancellationToken = default); + + /// Sets the @everyone-on-wipe flag (upserting the GuildSettings row). + /// The guild snowflake. + /// The new flag value. + /// A cancellation token. + /// A task that completes when the flag has been persisted. + Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, CancellationToken cancellationToken = default); +``` + +In `src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs`, add right after the `SetCultureAsync` implementation (mirroring its upsert shape): + +```csharp + /// + public async Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationToken cancellationToken = default) + { + var settings = await context.GuildSettings + .SingleOrDefaultAsync(s => s.GuildId == guildId, cancellationToken) + .ConfigureAwait(false); + return settings?.PingEveryoneOnWipe ?? false; + } + + /// + public async Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, CancellationToken cancellationToken = default) + { + var settings = await context.GuildSettings + .SingleOrDefaultAsync(s => s.GuildId == guildId, cancellationToken) + .ConfigureAwait(false); + + if (settings is null) + { + context.GuildSettings.Add(new GuildSettings + { + GuildId = guildId, PingEveryoneOnWipe = enabled + }); + } + else + { + settings.PingEveryoneOnWipe = enabled; + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +``` + +In `src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs`, add a using `RustPlusBot.Persistence.Wipes;` and register after the `IMapSettingsStore` line: + +```csharp + services.AddScoped(); +``` + +Note: adding methods to `IWorkspaceStore` is safe for existing NSubstitute mocks (substitutes auto-implement new members returning defaults — `false` here, which is the correct default). + +- [ ] **Step 4: Run the tests** + +Run: `dotnet test tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj -maxcpucount:1` +Expected: PASS (8 new tests green, no regressions). + +- [ ] **Step 5: Cleanup + commit** + +```bash +dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx +git add -A +git commit -m "Add wipe baseline store and wipe-ping workspace accessors + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: Features.Wipes project + ServerWipedEvent + WipeDetector + +The new feature project, the bus event, and the diff logic. The project is registered in the solution and Host now; renderer/poster/announcer/hosted-service arrive in Tasks 4–6. + +**Files:** +- Create: `src/RustPlusBot.Abstractions/Events/ServerWipedEvent.cs` +- Create: `src/RustPlusBot.Features.Wipes/RustPlusBot.Features.Wipes.csproj` +- Create: `src/RustPlusBot.Features.Wipes/Detection/IWipeDetector.cs` +- Create: `src/RustPlusBot.Features.Wipes/Detection/WipeDetector.cs` +- Create: `src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs` +- Create: `tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj` +- Modify: `RustPlusBot.slnx` (two `` entries) +- Modify: `src/RustPlusBot.Host/Program.cs` (`AddWipes()`) +- Test: `tests/RustPlusBot.Features.Wipes.Tests/WipeDetectorTests.cs` (create) + +**Interfaces:** +- Consumes: `IRustServerQuery.GetServerInfoAsync/GetWorldAsync` (singleton), `IWipeBaselineStore` (scoped, Task 2), `IEventBus.PublishAsync`. +- Produces: + +```csharp +public sealed record ServerWipedEvent( + ulong GuildId, + Guid ServerId, + DateTimeOffset? PreviousWipeTimeUtc, + DateTimeOffset? NewWipeTimeUtc, + uint Seed, + uint WorldSize); + +internal interface IWipeDetector +{ + Task CheckAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); +} +``` + +- [ ] **Step 1: Create the projects and wire them up** + +Create `src/RustPlusBot.Abstractions/Events/ServerWipedEvent.cs`: + +```csharp +namespace RustPlusBot.Abstractions.Events; + +/// Published when a reconnected server's wipe baseline no longer matches (the server wiped while we were away or restarting). +/// The owning guild snowflake. +/// The wiped server id. +/// The baseline wipe time before this wipe, or null if never observed. +/// The freshly observed wipe time, or null when the server does not report one. +/// The new procedural map seed. +/// The new world size (game units). +public sealed record ServerWipedEvent( + ulong GuildId, + Guid ServerId, + DateTimeOffset? PreviousWipeTimeUtc, + DateTimeOffset? NewWipeTimeUtc, + uint Seed, + uint WorldSize); +``` + +Create `src/RustPlusBot.Features.Wipes/RustPlusBot.Features.Wipes.csproj`: + +```xml + + + + + + + + + + + + + + + + + + + + + + +``` + +Create `tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj`: + +```xml + + + + + + + + + + + + + + + + + + + + +``` + +In `RustPlusBot.slnx`, add inside the `/src/` folder (after the `Features.Workspace` line): + +```xml + +``` + +and inside the `/tests/` folder (after the `Features.Workspace.Tests` line): + +```xml + +``` + +- [ ] **Step 2: Write the failing detector tests** + +Create `tests/RustPlusBot.Features.Wipes.Tests/WipeDetectorTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Detection; +using RustPlusBot.Persistence.Wipes; + +namespace RustPlusBot.Features.Wipes.Tests; + +/// Unit tests for 's diff rules. +public sealed class WipeDetectorTests +{ + private static readonly DateTimeOffset OldWipe = new(2026, 6, 4, 18, 0, 0, TimeSpan.Zero); + private static readonly Guid ServerId = Guid.NewGuid(); + + private sealed record Harness(WipeDetector Detector, IRustServerQuery Query, IWipeBaselineStore Store, IEventBus Bus); + + private static Harness Create( + ServerInfoSnapshot? info, + WorldSnapshot? world, + WipeBaseline? baseline) + { + var query = Substitute.For(); + query.GetServerInfoAsync(10UL, ServerId, Arg.Any()).Returns(info); + query.GetWorldAsync(10UL, ServerId, Arg.Any()).Returns(world); + + var store = Substitute.For(); + store.GetAsync(10UL, ServerId, Arg.Any()).Returns(baseline); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var bus = Substitute.For(); + var detector = new WipeDetector(scopeFactory, query, bus, NullLogger.Instance); + return new Harness(detector, query, store, bus); + } + + private static ServerInfoSnapshot Info(DateTimeOffset? wipeTime) => new(5, 100, 0, wipeTime); + + [Fact] + public async Task Null_info_snapshot_aborts_silently() + { + var h = Create(info: null, world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().SetAsync(default, default, null!, default); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Null_world_snapshot_aborts_silently() + { + var h = Create(info: Info(OldWipe), world: null, baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Missing_server_row_aborts_silently() + { + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), baseline: null); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().SetAsync(default, default, null!, default); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Empty_baseline_backfills_without_event() + { + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(null, null, null)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(OldWipe, 42u, 3500u), Arg.Any()); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Unchanged_baseline_is_a_noop() + { + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().SetAsync(default, default, null!, default); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Wipe_time_advance_beyond_tolerance_publishes_event() + { + var newWipe = OldWipe.AddDays(7); + var h = Create(info: Info(newWipe), world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(newWipe, 42u, 3500u), Arg.Any()); + await h.Bus.Received(1).PublishAsync( + new ServerWipedEvent(10UL, ServerId, OldWipe, newWipe, 42u, 3500u), + Arg.Any()); + } + + [Fact] + public async Task Wipe_time_jitter_within_tolerance_refreshes_baseline_without_event() + { + var jittered = OldWipe.AddSeconds(30); + var h = Create(info: Info(jittered), world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(jittered, 42u, 3500u), Arg.Any()); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Seed_change_publishes_event() + { + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 999u), baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Bus.Received(1).PublishAsync( + new ServerWipedEvent(10UL, ServerId, OldWipe, OldWipe, 999u, 3500u), + Arg.Any()); + } + + [Fact] + public async Task Size_change_publishes_event() + { + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(4250u, 42u), baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Bus.Received(1).PublishAsync( + new ServerWipedEvent(10UL, ServerId, OldWipe, OldWipe, 42u, 4250u), + Arg.Any()); + } + + [Fact] + public async Task Wipe_time_appearing_from_null_backfills_without_event() + { + // Baseline had seed/size but no wipe time (server started reporting it): not a wipe. + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), baseline: new WipeBaseline(null, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(OldWipe, 42u, 3500u), Arg.Any()); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } +} +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1` +Expected: FAIL — `WipeDetector` does not exist. + +- [ ] **Step 4: Implement the detector** + +Create `src/RustPlusBot.Features.Wipes/Detection/IWipeDetector.cs`: + +```csharp +namespace RustPlusBot.Features.Wipes.Detection; + +/// Checks a freshly connected server against its persisted wipe baseline. +internal interface IWipeDetector +{ + /// Reads live info/world, diffs against the baseline, and publishes on a wipe. + /// The owning guild snowflake. + /// The server that just connected. + /// A cancellation token. + /// A task that completes when the check (and any event publication) has finished. + Task CheckAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); +} +``` + +Create `src/RustPlusBot.Features.Wipes/Detection/WipeDetector.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Persistence.Wipes; + +namespace RustPlusBot.Features.Wipes.Detection; + +/// +/// Default . A wipe always implies a server restart, so the check runs on each +/// transition to connected: the server wiped when the wipe time advanced beyond a small jitter tolerance +/// or the map seed/size changed. The first-ever observation backfills the baseline silently so existing +/// deployments never see a false wipe on upgrade. +/// +/// Opens scopes for the scoped baseline store. +/// Reads live server info from the socket. +/// Publishes . +/// The logger. +internal sealed partial class WipeDetector( + IServiceScopeFactory scopeFactory, + IRustServerQuery query, + IEventBus eventBus, + ILogger logger) : IWipeDetector +{ + /// Absorbs clock jitter in the reported wipe time across restarts; anything larger counts as a wipe. + private static readonly TimeSpan _wipeTimeTolerance = TimeSpan.FromMinutes(1); + + /// + public async Task CheckAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + var info = await query.GetServerInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + var world = await query.GetWorldAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (info is null || world is null) + { + return; // Socket dropped mid-check; the next reconnect retries. + } + + var observed = new WipeBaseline(info.WipeTimeUtc, world.Seed, world.WorldSize); + WipeBaseline? baseline; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + baseline = await store.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (baseline is null || baseline == observed) + { + return; // Server removed mid-check, or plain reconnect with nothing changed. + } + + await store.SetAsync(guildId, serverId, observed, cancellationToken).ConfigureAwait(false); + } + + if (baseline is { WipeTimeUtc: null, MapSeed: null, MapSize: null }) + { + LogBaselineStored(logger, guildId, serverId); + return; // First observation ever: backfill silently. + } + + var wiped = + (info.WipeTimeUtc is { } newWipe && baseline.WipeTimeUtc is { } oldWipe + && newWipe > oldWipe + _wipeTimeTolerance) + || (baseline.MapSeed is { } oldSeed && world.Seed != oldSeed) + || (baseline.MapSize is { } oldSize && world.WorldSize != oldSize); + if (!wiped) + { + return; // Jitter within tolerance or a null field backfilled — baseline refreshed silently. + } + + await eventBus.PublishAsync( + new ServerWipedEvent(guildId, serverId, baseline.WipeTimeUtc, info.WipeTimeUtc, world.Seed, + world.WorldSize), + cancellationToken) + .ConfigureAwait(false); + LogWipeDetected(logger, guildId, serverId, world.Seed, world.WorldSize); + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "Stored first wipe baseline for guild {GuildId} server {ServerId}.")] + private static partial void LogBaselineStored(ILogger logger, ulong guildId, Guid serverId); + + [LoggerMessage(Level = LogLevel.Information, + Message = "Wipe detected for guild {GuildId} server {ServerId}: seed {Seed}, size {WorldSize}.")] + private static partial void LogWipeDetected(ILogger logger, ulong guildId, Guid serverId, uint seed, + uint worldSize); +} +``` + +Create `src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Wipes.Detection; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Wipes; + +/// DI registration for the wipe-detection feature. +public static class WipeServiceCollectionExtensions +{ + /// Registers the wipe detector (announcer/renderer/poster/hosted service are added by later slices). + /// The service collection to add to. + /// The same service collection, for chaining. + public static IServiceCollection AddWipes(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddRustPlusBotLocalization(); + services.AddSingleton(); + + return services; + } +} +``` + +In `src/RustPlusBot.Host/Program.cs`, add `using RustPlusBot.Features.Wipes;` with the other feature usings and, right after `builder.Services.AddStorageMonitors();`: + +```csharp +builder.Services.AddWipes(); +``` + +- [ ] **Step 5: Build + run the tests** + +Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` then `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1` +Expected: build clean, 10 detector tests PASS. + +- [ ] **Step 6: Cleanup + commit** + +```bash +dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx +git add -A +git commit -m "Add Features.Wipes project with wipe detector and ServerWipedEvent + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: Wipe announcement embed renderer + localization keys + +**Files:** +- Create: `src/RustPlusBot.Features.Wipes/Rendering/WipeEmbedRenderer.cs` +- Modify: `src/RustPlusBot.Localization/Strings.resx` +- Modify: `src/RustPlusBot.Localization/Strings.fr.resx` +- Modify: `src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs` (register renderer) +- Test: `tests/RustPlusBot.Features.Wipes.Tests/WipeEmbedRendererTests.cs` (create) + +**Interfaces:** +- Consumes: `ServerWipedEvent` (Task 3), `ILocalizer`. +- Produces: `internal sealed class WipeEmbedRenderer` with `Embed Render(ServerWipedEvent evt, string culture)` — Task 5's announcer calls it. + +- [ ] **Step 1: Write the failing renderer tests** + +Create `tests/RustPlusBot.Features.Wipes.Tests/WipeEmbedRendererTests.cs`: + +```csharp +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Rendering; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Wipes.Tests; + +/// Unit tests for . +public sealed class WipeEmbedRendererTests +{ + private static readonly DateTimeOffset WipedAt = new(2026, 7, 2, 18, 0, 0, TimeSpan.Zero); + + private static ServerWipedEvent Event(DateTimeOffset? newWipe = null) => + new(10UL, Guid.NewGuid(), WipedAt.AddDays(-7), newWipe ?? WipedAt, 999u, 4250u); + + [Fact] + public void Render_includes_title_body_and_fields() + { + var renderer = new WipeEmbedRenderer(new ResxLocalizer()); + + var embed = renderer.Render(Event(), "en"); + + Assert.Equal("🧹 Server wiped", embed.Title); + Assert.False(string.IsNullOrEmpty(embed.Description)); + Assert.Contains(embed.Fields, f => f.Value == $""); + Assert.Contains(embed.Fields, f => f.Value == "4250"); + Assert.Contains(embed.Fields, f => f.Value == "999"); + } + + [Fact] + public void Render_omits_wiped_at_field_when_wipe_time_unknown() + { + var renderer = new WipeEmbedRenderer(new ResxLocalizer()); + + var embed = renderer.Render(Event() with + { + NewWipeTimeUtc = null + }, "en"); + + Assert.Equal(2, embed.Fields.Length); + } + + [Fact] + public void Render_localizes_to_french() + { + var renderer = new WipeEmbedRenderer(new ResxLocalizer()); + + var en = renderer.Render(Event(), "en"); + var fr = renderer.Render(Event(), "fr"); + + Assert.NotEqual(en.Description, fr.Description); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1 --filter WipeEmbedRendererTests` +Expected: FAIL — `WipeEmbedRenderer` does not exist. + +- [ ] **Step 3: Add the localization keys** + +In `src/RustPlusBot.Localization/Strings.resx`, add before the closing `` (same `` shape as the existing entries): + +```xml + + 🧹 Server wiped + + + The server has wiped. All paired smart devices were removed — pair them again in game to keep using your switches, alarms and storage monitors. + + + Wiped + + + Map size + + + Seed + + + 🔔 Ping @everyone on wipe: On + + + 🔕 Ping @everyone on wipe: Off + +``` + +In `src/RustPlusBot.Localization/Strings.fr.resx`, add the same seven keys: + +```xml + + 🧹 Serveur wipé + + + Le serveur a wipé. Tous les appareils connectés appairés ont été supprimés — appairez-les à nouveau en jeu pour continuer à utiliser vos interrupteurs, alarmes et moniteurs de stockage. + + + Wipe + + + Taille de la carte + + + Seed + + + 🔔 Ping @everyone au wipe : activé + + + 🔕 Ping @everyone au wipe : désactivé + +``` + +(The `settings.wipeping.*` keys are consumed in Task 9; adding them now keeps the resx edits in one place and the parity test green throughout.) + +- [ ] **Step 4: Implement the renderer** + +Create `src/RustPlusBot.Features.Wipes/Rendering/WipeEmbedRenderer.cs`: + +```csharp +using System.Globalization; +using Discord; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Wipes.Rendering; + +/// Renders the server-wiped announcement embed posted in #events. +/// String resolution. +internal sealed class WipeEmbedRenderer(ILocalizer localizer) +{ + /// Renders the announcement for a guild culture. + /// The wipe event. + /// The guild culture ("en"/"fr"). + /// The built embed. + public Embed Render(ServerWipedEvent evt, string culture) + { + ArgumentNullException.ThrowIfNull(evt); + var builder = new EmbedBuilder() + .WithTitle(localizer.Get("wipe.title", culture)) + .WithDescription(localizer.Get("wipe.body", culture)) + .WithColor(Color.Orange); + + if (evt.NewWipeTimeUtc is { } wipedAt) + { + builder.AddField(localizer.Get("wipe.field.wipedat", culture), + $"", inline: true); + } + + builder + .AddField(localizer.Get("wipe.field.mapsize", culture), + evt.WorldSize.ToString(CultureInfo.InvariantCulture), inline: true) + .AddField(localizer.Get("wipe.field.seed", culture), + evt.Seed.ToString(CultureInfo.InvariantCulture), inline: true); + return builder.Build(); + } +} +``` + +In `WipeServiceCollectionExtensions.AddWipes`, add (with `using RustPlusBot.Features.Wipes.Rendering;`): + +```csharp + services.AddSingleton(); +``` + +- [ ] **Step 5: Run the tests** + +Run: `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1` and `dotnet test tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj -maxcpucount:1` +Expected: PASS (renderer tests + resx parity green). + +- [ ] **Step 6: Cleanup + commit** + +```bash +dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx +git add -A +git commit -m "Add wipe announcement embed renderer and localization keys + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: Wipe channel poster + announcer + +Posting a one-off message (optional `@everyone` content + embed) to the per-server #events channel. + +**Files:** +- Create: `src/RustPlusBot.Features.Wipes/Posting/IWipeChannelPoster.cs` +- Create: `src/RustPlusBot.Features.Wipes/Posting/DiscordWipeChannelPoster.cs` +- Create: `src/RustPlusBot.Features.Wipes/Announcing/IWipeAnnouncer.cs` +- Create: `src/RustPlusBot.Features.Wipes/Announcing/WipeAnnouncer.cs` +- Modify: `src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs` +- Test: `tests/RustPlusBot.Features.Wipes.Tests/WipeAnnouncerTests.cs` (create) + +**Interfaces:** +- Consumes: `IEventChannelLocator` (Workspace singleton), `IWorkspaceStore.GetCultureAsync`/`GetPingEveryoneOnWipeAsync` (scoped), `WipeEmbedRenderer` (Task 4), `ServerWipedEvent`. +- Produces: + +```csharp +internal interface IWipeChannelPoster +{ + Task PostAsync(ulong channelId, string? content, Embed embed, CancellationToken cancellationToken); +} + +internal interface IWipeAnnouncer +{ + Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken cancellationToken); +} +``` + +- [ ] **Step 1: Write the failing announcer tests** + +Create `tests/RustPlusBot.Features.Wipes.Tests/WipeAnnouncerTests.cs`: + +```csharp +using Discord; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Announcing; +using RustPlusBot.Features.Wipes.Posting; +using RustPlusBot.Features.Wipes.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Wipes.Tests; + +/// Unit tests for . +public sealed class WipeAnnouncerTests +{ + private static readonly Guid ServerId = Guid.NewGuid(); + + private sealed record Harness(WipeAnnouncer Announcer, IEventChannelLocator Locator, IWipeChannelPoster Poster); + + private static Harness Create(ulong? channelId = 777UL, bool ping = false, string culture = "en") + { + var workspace = Substitute.For(); + workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns(culture); + workspace.GetPingEveryoneOnWipeAsync(Arg.Any(), Arg.Any()).Returns(ping); + + var services = new ServiceCollection(); + services.AddScoped(_ => workspace); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(channelId); + var poster = Substitute.For(); + + var announcer = new WipeAnnouncer( + scopeFactory, + locator, + new WipeEmbedRenderer(new ResxLocalizer()), + poster, + NullLogger.Instance); + return new Harness(announcer, locator, poster); + } + + private static ServerWipedEvent Event() => + new(10UL, ServerId, null, new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), 999u, 4250u); + + [Fact] + public async Task Missing_events_channel_skips_posting() + { + var h = Create(channelId: null); + + await h.Announcer.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Poster.DidNotReceiveWithAnyArgs().PostAsync(default, default, null!, default); + } + + [Fact] + public async Task Posts_embed_without_ping_by_default() + { + var h = Create(); + + await h.Announcer.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Poster.Received(1).PostAsync( + 777UL, + null, + Arg.Is(e => e.Title == "🧹 Server wiped"), + Arg.Any()); + } + + [Fact] + public async Task Posts_everyone_content_when_guild_setting_enabled() + { + var h = Create(ping: true); + + await h.Announcer.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Poster.Received(1).PostAsync( + 777UL, + "@everyone", + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Renders_with_guild_culture() + { + var h = Create(culture: "fr"); + + await h.Announcer.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Poster.Received(1).PostAsync( + 777UL, + null, + Arg.Is(e => e.Title == "🧹 Serveur wipé"), + Arg.Any()); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1 --filter WipeAnnouncerTests` +Expected: FAIL — types missing. + +- [ ] **Step 3: Implement poster and announcer** + +Create `src/RustPlusBot.Features.Wipes/Posting/IWipeChannelPoster.cs`: + +```csharp +using Discord; + +namespace RustPlusBot.Features.Wipes.Posting; + +/// Posts the one-off wipe announcement to a Discord channel. +internal interface IWipeChannelPoster +{ + /// Posts (with optional mention ) to the channel. + /// The target Discord channel id. + /// Optional message content (e.g. "@everyone"), or null for embed-only. + /// The announcement embed. + /// A cancellation token. + /// A task that completes when the message has been sent (or the failure swallowed). + Task PostAsync(ulong channelId, string? content, Embed embed, CancellationToken cancellationToken); +} +``` + +Create `src/RustPlusBot.Features.Wipes/Posting/DiscordWipeChannelPoster.cs` (mirrors `DiscordAlarmChannelPoster.SendEveryonePingAsync`): + +```csharp +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; + +namespace RustPlusBot.Features.Wipes.Posting; + +/// Posts wipe announcements in #events via the gateway client. Untested integration shim. +/// The Discord socket client (raw send so the optional @everyone mention resolves). +/// The logger. +internal sealed partial class DiscordWipeChannelPoster( + DiscordSocketClient client, + ILogger logger) : IWipeChannelPoster +{ + /// + public async Task PostAsync(ulong channelId, string? content, Embed embed, CancellationToken cancellationToken) + { + try + { + var options = new RequestOptions + { + CancelToken = cancellationToken + }; + if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) + is not ITextChannel channel) + { + return; + } + + await channel.SendMessageAsync( + content, + embed: embed, + options: options, + allowedMentions: AllowedMentions.All) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Shutdown — let the loop unwind. + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the wipe loop; swallow the failure. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPostFailed(logger, ex, channelId); + } + } + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Posting the wipe announcement in channel {ChannelId} failed.")] + private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId); +} +``` + +Create `src/RustPlusBot.Features.Wipes/Announcing/IWipeAnnouncer.cs`: + +```csharp +using RustPlusBot.Abstractions.Events; + +namespace RustPlusBot.Features.Wipes.Announcing; + +/// Posts the wipe announcement embed to the wiped server's #events channel. +internal interface IWipeAnnouncer +{ + /// Handles one . + /// The wipe event. + /// A cancellation token. + /// A task that completes when the announcement has been posted (or skipped). + Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken cancellationToken); +} +``` + +Create `src/RustPlusBot.Features.Wipes/Announcing/WipeAnnouncer.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Posting; +using RustPlusBot.Features.Wipes.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Wipes.Announcing; + +/// Default : resolves #events, the guild culture and the ping flag, then posts. +/// Opens scopes for the scoped workspace store. +/// Resolves the #events channel id. +/// Builds the announcement embed. +/// Sends the announcement message. +/// The logger. +internal sealed partial class WipeAnnouncer( + IServiceScopeFactory scopeFactory, + IEventChannelLocator locator, + WipeEmbedRenderer renderer, + IWipeChannelPoster poster, + ILogger logger) : IWipeAnnouncer +{ + /// + public async Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (channelId is not { } cid) + { + LogNoEventsChannel(logger, evt.GuildId, evt.ServerId); + return; + } + + string culture; + bool ping; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + culture = await workspace.GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + ping = await workspace.GetPingEveryoneOnWipeAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + } + + var embed = renderer.Render(evt, culture); + await poster.PostAsync(cid, ping ? "@everyone" : null, embed, cancellationToken).ConfigureAwait(false); + } + + [LoggerMessage(Level = LogLevel.Warning, + Message = "No #events channel for guild {GuildId} server {ServerId}; skipping wipe announcement.")] + private static partial void LogNoEventsChannel(ILogger logger, ulong guildId, Guid serverId); +} +``` + +In `WipeServiceCollectionExtensions.AddWipes`, add (with usings for `.Announcing` and `.Posting`): + +```csharp + services.AddSingleton(); + services.AddSingleton(); +``` + +- [ ] **Step 4: Run the tests** + +Run: `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1` +Expected: PASS (all Wipes tests green). + +- [ ] **Step 5: Cleanup + commit** + +```bash +dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx +git add -A +git commit -m "Add wipe channel poster and announcer + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 6: WipesHostedService (bus wiring) + registration test + +**Files:** +- Create: `src/RustPlusBot.Features.Wipes/Hosting/WipesHostedService.cs` +- Modify: `src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs` +- Test: `tests/RustPlusBot.Features.Wipes.Tests/Hosting/WipesHostedServiceTests.cs` (create) +- Test: `tests/RustPlusBot.Features.Wipes.Tests/WipeRegistrationTests.cs` (create) + +**Interfaces:** +- Consumes: `IEventBus`, `IWipeDetector` (Task 3), `IWipeAnnouncer` (Task 5), `ConnectionStatusChangedEvent`, `ServerWipedEvent`. +- Produces: the running feature. Nothing downstream consumes new APIs. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/RustPlusBot.Features.Wipes.Tests/Hosting/WipesHostedServiceTests.cs` (poll-until-deadline pattern copied from `AlarmsHostedServiceTests`): + +```csharp +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Announcing; +using RustPlusBot.Features.Wipes.Detection; +using RustPlusBot.Features.Wipes.Hosting; + +namespace RustPlusBot.Features.Wipes.Tests.Hosting; + +/// Verifies routes bus events to the detector and announcer. +public sealed class WipesHostedServiceTests +{ + private sealed record Harness( + WipesHostedService Service, + InMemoryEventBus Bus, + IWipeDetector Detector, + IWipeAnnouncer Announcer); + + private static Harness Create() + { + var detector = Substitute.For(); + var announcer = Substitute.For(); + var bus = new InMemoryEventBus(); + var service = new WipesHostedService(bus, detector, announcer, + NullLogger.Instance); + return new Harness(service, bus, detector, announcer); + } + + private static async Task WaitForCallAsync(object substitute, string methodName) + { + // Same poll-until-deadline shape as AlarmsHostedServiceTests: the consumer loops attach + // asynchronously after StartAsync, so poll ReceivedCalls() instead of asserting immediately. + var deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline + && !substitute.ReceivedCalls().Any(c => c.GetMethodInfo().Name == methodName)) + { + await Task.Delay(25); + } + } + + [Fact] + public async Task Connected_transition_runs_the_detector() + { + var h = Create(); + await h.Service.StartAsync(default); + var serverId = Guid.NewGuid(); + + await h.Bus.PublishAsync(new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: true, + WasConnected: false)); + await WaitForCallAsync(h.Detector, nameof(IWipeDetector.CheckAsync)); + + await h.Detector.Received(1).CheckAsync(10UL, serverId, Arg.Any()); + await h.Service.StopAsync(default); + } + + [Fact] + public async Task Disconnect_does_not_run_the_detector() + { + var h = Create(); + await h.Service.StartAsync(default); + + await h.Bus.PublishAsync(new ConnectionStatusChangedEvent(10UL, Guid.NewGuid(), IsConnected: false, + WasConnected: true)); + await Task.Delay(200); + + await h.Detector.DidNotReceiveWithAnyArgs().CheckAsync(default, default, default); + await h.Service.StopAsync(default); + } + + [Fact] + public async Task ServerWipedEvent_routes_to_the_announcer() + { + var h = Create(); + await h.Service.StartAsync(default); + var evt = new ServerWipedEvent(10UL, Guid.NewGuid(), null, null, 1u, 3500u); + + await h.Bus.PublishAsync(evt); + await WaitForCallAsync(h.Announcer, nameof(IWipeAnnouncer.HandleServerWipedAsync)); + + await h.Announcer.Received(1).HandleServerWipedAsync(evt, Arg.Any()); + await h.Service.StopAsync(default); + } +} +``` + +> `ReceivedCalls()` is the public NSubstitute extension (`using NSubstitute;`) — the same one `AlarmsHostedServiceTests` polls. + +Create `tests/RustPlusBot.Features.Wipes.Tests/WipeRegistrationTests.cs` (mirrors `AlarmRegistrationTests`): + +```csharp +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Announcing; +using RustPlusBot.Features.Wipes.Detection; +using RustPlusBot.Features.Wipes.Posting; +using RustPlusBot.Features.Wipes.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Wipes; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Wipes.Tests; + +/// Validates that registers all required services. +public sealed class WipeRegistrationTests +{ + /// Verifies core service descriptors are present after calling . + [Fact] + public void AddWipes_registers_core_services() + { + var services = new ServiceCollection(); + services.AddWipes(); + + Assert.Contains(services, d => d.ServiceType == typeof(ILocalizer)); + Assert.Contains(services, d => d.ServiceType == typeof(IWipeDetector)); + Assert.Contains(services, d => d.ServiceType == typeof(WipeEmbedRenderer)); + Assert.Contains(services, d => d.ServiceType == typeof(IWipeChannelPoster)); + Assert.Contains(services, d => d.ServiceType == typeof(IWipeAnnouncer)); + Assert.Contains(services, d => d.ServiceType == typeof(IHostedService)); + } + + /// Verifies the container resolves key types without captive-dependency errors when all cross-layer deps are provided. + [Fact] + public void AddWipes_resolves_without_captive_dependency_errors() + { + var services = new ServiceCollection(); + + services.AddLogging(); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(new DiscordSocketClient(new DiscordSocketConfig())); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + + services.AddWipes(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1 --filter "WipesHostedServiceTests|WipeRegistrationTests"` +Expected: FAIL — `WipesHostedService` does not exist / `IHostedService` not registered. + +- [ ] **Step 3: Implement the hosted service** + +Create `src/RustPlusBot.Features.Wipes/Hosting/WipesHostedService.cs`: + +```csharp +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Announcing; +using RustPlusBot.Features.Wipes.Detection; + +namespace RustPlusBot.Features.Wipes.Hosting; + +/// Runs the wipe-check loop (on connected transitions) and the wipe-announcement loop. +/// The in-process event bus. +/// Diffs the live server against the persisted baseline. +/// Posts the wipe announcement in #events. +/// The logger. +internal sealed partial class WipesHostedService( + IEventBus eventBus, + IWipeDetector detector, + IWipeAnnouncer announcer, + ILogger logger) : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private Task? _statusLoop; + private Task? _wipedLoop; + + /// + public void Dispose() => _cts.Dispose(); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None); + _wipedLoop = Task.Run(() => ConsumeWipedAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync().ConfigureAwait(false); + foreach (var loop in new[] + { + _statusLoop, _wipedLoop + }.Where(t => t is not null)) + { + try + { +#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop. + await loop!.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + } + + private async Task ConsumeStatusAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + if (!evt.IsConnected) + { + continue; + } + + await detector.CheckAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogStatusLoopFaulted(logger, ex); + } + } + + private async Task ConsumeWipedAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await announcer.HandleServerWipedAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogWipedLoopFaulted(logger, ex); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Wipe connection-status loop faulted.")] + private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Wipe announcement loop faulted.")] + private static partial void LogWipedLoopFaulted(ILogger logger, Exception exception); +} +``` + +In `WipeServiceCollectionExtensions.AddWipes`, add (with `using Microsoft.Extensions.DependencyInjection;` already present and `using RustPlusBot.Features.Wipes.Hosting;`): + +```csharp + services.AddHostedService(); +``` + +- [ ] **Step 4: Run the tests** + +Run: `dotnet test tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj -maxcpucount:1` +Expected: PASS. + +- [ ] **Step 5: Cleanup + commit** + +```bash +dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx +git add -A +git commit -m "Wire wipe detection and announcement through a hosted service + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 7: Purge smart alarms on wipe + +Delete alarm rows + their #alarms embeds when `ServerWipedEvent` fires. Rows are removed first (kills any late trigger notification), messages best-effort after. + +**Files:** +- Modify: `src/RustPlusBot.Features.Alarms/Posting/IAlarmChannelPoster.cs` (add `DeleteMessageAsync`) +- Modify: `src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs` +- Create: `src/RustPlusBot.Features.Alarms/Relaying/AlarmWipePurger.cs` +- Modify: `src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs` (new consumer loop + constructor param) +- Modify: `src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs` +- Modify: `tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs` (constructor call) +- Test: `tests/RustPlusBot.Features.Alarms.Tests/AlarmWipePurgerTests.cs` (create) + +**Interfaces:** +- Consumes: `ServerWipedEvent`, `IAlarmStore.ListByServerAsync(ulong, Guid, CancellationToken)` / `RemoveAsync(ulong, Guid, ulong, CancellationToken)`, `IAlarmChannelLocator.GetChannelIdAsync`. +- Produces: + +```csharp +// on IAlarmChannelPoster: +Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken); + +// new: +internal sealed partial class AlarmWipePurger +{ + public Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken ct); +} +``` + +- [ ] **Step 1: Write the failing purger tests** + +Create `tests/RustPlusBot.Features.Alarms.Tests/AlarmWipePurgerTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Alarms; +using RustPlusBot.Features.Alarms.Posting; +using RustPlusBot.Features.Alarms.Relaying; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Alarms; + +namespace RustPlusBot.Features.Alarms.Tests; + +/// Unit tests for . +public sealed class AlarmWipePurgerTests +{ + private static readonly Guid ServerId = Guid.NewGuid(); + + private sealed record Harness( + AlarmWipePurger Purger, + IAlarmStore Store, + IAlarmChannelLocator Locator, + IAlarmChannelPoster Poster); + + private static Harness Create(IReadOnlyList alarms, ulong? channelId = 777UL) + { + var store = Substitute.For(); + store.ListByServerAsync(10UL, ServerId, Arg.Any()).Returns(alarms); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(10UL, ServerId, Arg.Any()).Returns(channelId); + var poster = Substitute.For(); + + var purger = new AlarmWipePurger(scopeFactory, locator, poster, + NullLogger.Instance); + return new Harness(purger, store, locator, poster); + } + + private static SmartAlarm Alarm(ulong entityId, ulong? messageId) => new() + { + GuildId = 10UL, ServerId = ServerId, EntityId = entityId, Name = $"Alarm {entityId}", MessageId = messageId + }; + + private static ServerWipedEvent Event() => new(10UL, ServerId, null, null, 1u, 3500u); + + [Fact] + public async Task Removes_rows_and_deletes_messages() + { + var h = Create(new[] + { + Alarm(1UL, 100UL), Alarm(2UL, 200UL) + }); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 2UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 100UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 200UL, Arg.Any()); + } + + [Fact] + public async Task Alarm_without_message_is_removed_without_delete_call() + { + var h = Create(new[] + { + Alarm(1UL, messageId: null) + }); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task Missing_channel_still_removes_rows() + { + var h = Create(new[] + { + Alarm(1UL, 100UL) + }, channelId: null); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task No_alarms_is_a_noop() + { + var h = Create(Array.Empty()); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().RemoveAsync(default, default, default, default); + await h.Locator.DidNotReceiveWithAnyArgs().GetChannelIdAsync(default, default, default); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj -maxcpucount:1 --filter AlarmWipePurgerTests` +Expected: FAIL — `AlarmWipePurger` / `DeleteMessageAsync` do not exist. + +- [ ] **Step 3: Implement** + +In `src/RustPlusBot.Features.Alarms/Posting/IAlarmChannelPoster.cs`, add after `SendEveryonePingAsync`: + +```csharp + /// Deletes a message in the given channel (missing message/channel tolerated). + /// The #alarms channel id. + /// The message id to delete. + /// A cancellation token. + /// A task that completes when the message has been deleted (or the failure swallowed). + Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken); +``` + +In `src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs`, add the implementation (the class already injects `DiscordSocketClient client`): + +```csharp + /// + public async Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken) + { + try + { + var options = new RequestOptions + { + CancelToken = cancellationToken + }; + if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) + is not ITextChannel channel) + { + return; + } + + await channel.DeleteMessageAsync(messageId, options).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Shutdown — let the loop unwind. + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup (or already-deleted message) must not crash the purge. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDeleteFailed(logger, ex, messageId, channelId); + } + } + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Deleting message {MessageId} in channel {ChannelId} failed (may already be gone).")] + private static partial void LogDeleteFailed(ILogger logger, Exception exception, ulong messageId, + ulong channelId); +``` + +Create `src/RustPlusBot.Features.Alarms/Relaying/AlarmWipePurger.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Alarms; +using RustPlusBot.Features.Alarms.Posting; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Alarms; + +namespace RustPlusBot.Features.Alarms.Relaying; + +/// +/// Deletes all of a wiped server's alarms: entity ids are permanently invalid after a wipe, so the rows +/// are removed first (guaranteeing no stale trigger notifications), then the #alarms embeds best-effort. +/// Idempotent: a duplicate wipe event finds no rows and is a no-op. +/// +/// Opens scopes for the scoped alarm store. +/// Resolves the #alarms channel id. +/// Deletes the alarm embeds. +/// The logger. +internal sealed partial class AlarmWipePurger( + IServiceScopeFactory scopeFactory, + IAlarmChannelLocator locator, + IAlarmChannelPoster poster, + ILogger logger) +{ + /// Handles one by purging the server's alarms. + /// The wipe event. + /// A cancellation token. + /// A task that completes when rows and embeds have been removed. + public async Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(evt); + IReadOnlyList alarms; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + alarms = await store.ListByServerAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + foreach (var alarm in alarms) + { + await store.RemoveAsync(evt.GuildId, evt.ServerId, alarm.EntityId, ct).ConfigureAwait(false); + } + } + + if (alarms.Count == 0) + { + return; + } + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + if (channelId is { } cid) + { + foreach (var alarm in alarms) + { + if (alarm.MessageId is { } messageId) + { + await poster.DeleteMessageAsync(cid, messageId, ct).ConfigureAwait(false); + } + } + } + + LogPurged(logger, alarms.Count, evt.GuildId, evt.ServerId); + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "Purged {Count} alarms after wipe for guild {GuildId} server {ServerId}.")] + private static partial void LogPurged(ILogger logger, int count, ulong guildId, Guid serverId); +} +``` + +In `src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs`: +- Add constructor parameter `AlarmWipePurger purger` (after `relay`), and update the XML `` docs. +- Add field `private Task? _wipedLoop;`, start it in `StartAsync` (`_wipedLoop = Task.Run(() => ConsumeWipedAsync(_cts.Token), CancellationToken.None);`), and add `_wipedLoop` to the `StopAsync` array. +- Add the consumer + log method: + +```csharp + private async Task ConsumeWipedAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await purger.HandleServerWipedAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogWipedLoopFaulted(logger, ex); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Alarm wipe-purge loop faulted.")] + private static partial void LogWipedLoopFaulted(ILogger logger, Exception exception); +``` + +In `src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs`, add: + +```csharp + services.AddSingleton(); +``` + +In `tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs`, the `new AlarmsHostedService(...)` call needs the new parameter. Inside `Create()`, before the service construction, add: + +```csharp + var purger = new AlarmWipePurger(scopeFactory, relayLocator, relayPoster, + NullLogger.Instance); +``` + +and pass `purger` as the argument after `relay`. + +- [ ] **Step 4: Run the tests** + +Run: `dotnet test tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj -maxcpucount:1` +Expected: PASS (4 new purger tests + all existing alarm tests green). + +While here, confirm the spec's stale-notification guarantee is covered: `AlarmStateRelayTests` already asserts that `HandleTriggeredAsync` for an entity with no stored alarm row is ignored (the `alarm is null` early return) — that existing test is exactly what makes a late trigger for a purged device harmless. If no such test exists, add one there: store returns null → `refresher`/`poster` receive no calls. + +- [ ] **Step 5: Cleanup + commit** + +```bash +dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx +git add -A +git commit -m "Purge smart alarms on server wipe + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 8: Purge smart switches on wipe + +Identical shape to Task 7, in the Switches feature. **The switch poster does not yet inject the socket client — add it.** + +**Files:** +- Modify: `src/RustPlusBot.Features.Switches/Posting/ISwitchChannelPoster.cs` (add `DeleteMessageAsync`, same signature/docs as Task 7) +- Modify: `src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs` +- Create: `src/RustPlusBot.Features.Switches/Relaying/SwitchWipePurger.cs` +- Modify: `src/RustPlusBot.Features.Switches/Hosting/SwitchesHostedService.cs` +- Modify: `src/RustPlusBot.Features.Switches/SwitchServiceCollectionExtensions.cs` +- Modify: `tests/RustPlusBot.Features.Switches.Tests/Hosting/SwitchesHostedServiceTests.cs` (constructor call — if this file doesn't exist, grep `tests/RustPlusBot.Features.Switches.Tests` for `new SwitchesHostedService(` and update every construction site) +- Test: `tests/RustPlusBot.Features.Switches.Tests/SwitchWipePurgerTests.cs` (create) + +**Interfaces:** +- Consumes: `ISwitchStore.ListByServerAsync(ulong, Guid, CancellationToken)` / `RemoveAsync(ulong, Guid, ulong, CancellationToken)` (same signatures as the alarm store), `ISwitchChannelLocator.GetChannelIdAsync`, `SmartSwitch.MessageId` (`ulong?`), `SmartSwitch.EntityId` (`ulong`). +- Produces: `internal sealed partial class SwitchWipePurger` with `Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken ct)`; `ISwitchChannelPoster.DeleteMessageAsync`. + +- [ ] **Step 1: Write the failing purger tests** + +Create `tests/RustPlusBot.Features.Switches.Tests/SwitchWipePurgerTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Switches; +using RustPlusBot.Features.Switches.Posting; +using RustPlusBot.Features.Switches.Relaying; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Switches; + +namespace RustPlusBot.Features.Switches.Tests; + +/// Unit tests for . +public sealed class SwitchWipePurgerTests +{ + private static readonly Guid ServerId = Guid.NewGuid(); + + private sealed record Harness( + SwitchWipePurger Purger, + ISwitchStore Store, + ISwitchChannelLocator Locator, + ISwitchChannelPoster Poster); + + private static Harness Create(IReadOnlyList switches, ulong? channelId = 777UL) + { + var store = Substitute.For(); + store.ListByServerAsync(10UL, ServerId, Arg.Any()).Returns(switches); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(10UL, ServerId, Arg.Any()).Returns(channelId); + var poster = Substitute.For(); + + var purger = new SwitchWipePurger(scopeFactory, locator, poster, + NullLogger.Instance); + return new Harness(purger, store, locator, poster); + } + + private static SmartSwitch Switch(ulong entityId, ulong? messageId) => new() + { + GuildId = 10UL, ServerId = ServerId, EntityId = entityId, Name = $"Switch {entityId}", MessageId = messageId + }; + + private static ServerWipedEvent Event() => new(10UL, ServerId, null, null, 1u, 3500u); + + [Fact] + public async Task Removes_rows_and_deletes_messages() + { + var h = Create(new[] + { + Switch(1UL, 100UL), Switch(2UL, 200UL) + }); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 2UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 100UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 200UL, Arg.Any()); + } + + [Fact] + public async Task Switch_without_message_is_removed_without_delete_call() + { + var h = Create(new[] + { + Switch(1UL, messageId: null) + }); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task Missing_channel_still_removes_rows() + { + var h = Create(new[] + { + Switch(1UL, 100UL) + }, channelId: null); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task No_switches_is_a_noop() + { + var h = Create(Array.Empty()); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().RemoveAsync(default, default, default, default); + await h.Locator.DidNotReceiveWithAnyArgs().GetChannelIdAsync(default, default, default); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj -maxcpucount:1 --filter SwitchWipePurgerTests` +Expected: FAIL — types missing. + +- [ ] **Step 3: Implement** + +In `src/RustPlusBot.Features.Switches/Posting/ISwitchChannelPoster.cs`, add: + +```csharp + /// Deletes a message in the given channel (missing message/channel tolerated). + /// The #switches channel id. + /// The message id to delete. + /// A cancellation token. + /// A task that completes when the message has been deleted (or the failure swallowed). + Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken); +``` + +In `src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs`: add `using Discord.WebSocket;`, change the declaration to + +```csharp +internal sealed partial class DiscordSwitchChannelPoster( + DiscordSocketClient client, + DiscordChannelMessenger messenger, + ILogger logger) : ISwitchChannelPoster +``` + +(add the `The Discord socket client (used directly for raw message deletes).` doc line; `DiscordSocketClient` is already a DI singleton from the Discord layer, so no registration change), and add: + +```csharp + /// + public async Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken) + { + try + { + var options = new RequestOptions + { + CancelToken = cancellationToken + }; + if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) + is not ITextChannel channel) + { + return; + } + + await channel.DeleteMessageAsync(messageId, options).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Shutdown — let the loop unwind. + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup (or already-deleted message) must not crash the purge. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDeleteFailed(logger, ex, messageId, channelId); + } + } + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Deleting message {MessageId} in channel {ChannelId} failed (may already be gone).")] + private static partial void LogDeleteFailed(ILogger logger, Exception exception, ulong messageId, + ulong channelId); +``` + +Create `src/RustPlusBot.Features.Switches/Relaying/SwitchWipePurger.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Switches; +using RustPlusBot.Features.Switches.Posting; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Switches; + +namespace RustPlusBot.Features.Switches.Relaying; + +/// +/// Deletes all of a wiped server's switches: entity ids are permanently invalid after a wipe, so the rows +/// are removed first, then the #switches embeds best-effort. Idempotent: a duplicate wipe event finds no +/// rows and is a no-op. +/// +/// Opens scopes for the scoped switch store. +/// Resolves the #switches channel id. +/// Deletes the switch embeds. +/// The logger. +internal sealed partial class SwitchWipePurger( + IServiceScopeFactory scopeFactory, + ISwitchChannelLocator locator, + ISwitchChannelPoster poster, + ILogger logger) +{ + /// Handles one by purging the server's switches. + /// The wipe event. + /// A cancellation token. + /// A task that completes when rows and embeds have been removed. + public async Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(evt); + IReadOnlyList switches; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + switches = await store.ListByServerAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + foreach (var device in switches) + { + await store.RemoveAsync(evt.GuildId, evt.ServerId, device.EntityId, ct).ConfigureAwait(false); + } + } + + if (switches.Count == 0) + { + return; + } + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + if (channelId is { } cid) + { + foreach (var device in switches) + { + if (device.MessageId is { } messageId) + { + await poster.DeleteMessageAsync(cid, messageId, ct).ConfigureAwait(false); + } + } + } + + LogPurged(logger, switches.Count, evt.GuildId, evt.ServerId); + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "Purged {Count} switches after wipe for guild {GuildId} server {ServerId}.")] + private static partial void LogPurged(ILogger logger, int count, ulong guildId, Guid serverId); +} +``` + +In `src/RustPlusBot.Features.Switches/Hosting/SwitchesHostedService.cs`: add constructor param `SwitchWipePurger purger` (update `` docs), field `private Task? _wipedLoop;`, start `_wipedLoop = Task.Run(() => ConsumeWipedAsync(_cts.Token), CancellationToken.None);` in `StartAsync`, add `_wipedLoop` to the `StopAsync` join array, and add: + +```csharp + private async Task ConsumeWipedAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await purger.HandleServerWipedAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogWipedLoopFaulted(logger, ex); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Switch wipe-purge loop faulted.")] + private static partial void LogWipedLoopFaulted(ILogger logger, Exception exception); +``` + +In `src/RustPlusBot.Features.Switches/SwitchServiceCollectionExtensions.cs`, add `services.AddSingleton();`. + +Update every `new SwitchesHostedService(` construction site in `tests/RustPlusBot.Features.Switches.Tests` (grep for it): build a purger from the harness's existing scope factory + locator/poster substitutes — + +```csharp + var purger = new SwitchWipePurger(scopeFactory, locator, poster, + NullLogger.Instance); +``` + +(adjust the variable names to that file's existing locator/poster substitutes) and pass it in the new constructor position. + +- [ ] **Step 4: Run the tests** + +Run: `dotnet test tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj -maxcpucount:1` +Expected: PASS. + +- [ ] **Step 5: Cleanup + commit** + +```bash +dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx +git add -A +git commit -m "Purge smart switches on server wipe + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 9: Purge storage monitors on wipe + +Same shape again, in the StorageMonitors feature (its poster also lacks the client — add it). + +**Files:** +- Modify: `src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs` +- Modify: `src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs` +- Create: `src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorWipePurger.cs` +- Modify: `src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs` +- Modify: `src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs` +- Modify: every `new StorageMonitorsHostedService(` construction site in `tests/RustPlusBot.Features.StorageMonitors.Tests` +- Test: `tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorWipePurgerTests.cs` (create) + +**Interfaces:** +- Consumes: `IStorageMonitorStore.ListByServerAsync/RemoveAsync` (same signatures as the alarm store), `IStorageMonitorChannelLocator.GetChannelIdAsync`, `SmartStorageMonitor.MessageId` (`ulong?`). +- Produces: `StorageMonitorWipePurger.HandleServerWipedAsync(ServerWipedEvent, CancellationToken)`; `IStorageMonitorChannelPoster.DeleteMessageAsync`. + +- [ ] **Step 1: Write the failing purger tests** + +Create `tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorWipePurgerTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.StorageMonitors; +using RustPlusBot.Features.StorageMonitors.Posting; +using RustPlusBot.Features.StorageMonitors.Relaying; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.StorageMonitors; + +namespace RustPlusBot.Features.StorageMonitors.Tests; + +/// Unit tests for . +public sealed class StorageMonitorWipePurgerTests +{ + private static readonly Guid ServerId = Guid.NewGuid(); + + private sealed record Harness( + StorageMonitorWipePurger Purger, + IStorageMonitorStore Store, + IStorageMonitorChannelLocator Locator, + IStorageMonitorChannelPoster Poster); + + private static Harness Create(IReadOnlyList monitors, ulong? channelId = 777UL) + { + var store = Substitute.For(); + store.ListByServerAsync(10UL, ServerId, Arg.Any()).Returns(monitors); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(10UL, ServerId, Arg.Any()).Returns(channelId); + var poster = Substitute.For(); + + var purger = new StorageMonitorWipePurger(scopeFactory, locator, poster, + NullLogger.Instance); + return new Harness(purger, store, locator, poster); + } + + private static SmartStorageMonitor Monitor(ulong entityId, ulong? messageId) => new() + { + GuildId = 10UL, ServerId = ServerId, EntityId = entityId, Name = $"Monitor {entityId}", MessageId = messageId + }; + + private static ServerWipedEvent Event() => new(10UL, ServerId, null, null, 1u, 3500u); + + [Fact] + public async Task Removes_rows_and_deletes_messages() + { + var h = Create(new[] + { + Monitor(1UL, 100UL), Monitor(2UL, 200UL) + }); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 2UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 100UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 200UL, Arg.Any()); + } + + [Fact] + public async Task Monitor_without_message_is_removed_without_delete_call() + { + var h = Create(new[] + { + Monitor(1UL, messageId: null) + }); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task Missing_channel_still_removes_rows() + { + var h = Create(new[] + { + Monitor(1UL, 100UL) + }, channelId: null); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task No_monitors_is_a_noop() + { + var h = Create(Array.Empty()); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().RemoveAsync(default, default, default, default); + await h.Locator.DidNotReceiveWithAnyArgs().GetChannelIdAsync(default, default, default); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj -maxcpucount:1 --filter StorageMonitorWipePurgerTests` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +In `src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs`, add: + +```csharp + /// Deletes a message in the given channel (missing message/channel tolerated). + /// The #storagemonitors channel id. + /// The message id to delete. + /// A cancellation token. + /// A task that completes when the message has been deleted (or the failure swallowed). + Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken); +``` + +In `src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs`: add `using Discord.WebSocket;`, change the declaration to + +```csharp +internal sealed partial class DiscordStorageMonitorChannelPoster( + DiscordSocketClient client, + DiscordChannelMessenger messenger, + ILogger logger) : IStorageMonitorChannelPoster +``` + +(add the `The Discord socket client (used directly for raw message deletes).` doc line), and add the same `DeleteMessageAsync` implementation + `LogDeleteFailed` `[LoggerMessage]` shown in full in Task 8 Step 3 — identical code, only the containing class differs. + +Create `src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorWipePurger.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.StorageMonitors; +using RustPlusBot.Features.StorageMonitors.Posting; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.StorageMonitors; + +namespace RustPlusBot.Features.StorageMonitors.Relaying; + +/// +/// Deletes all of a wiped server's storage monitors: entity ids are permanently invalid after a wipe, so +/// the rows are removed first, then the #storagemonitors embeds best-effort. Idempotent: a duplicate wipe +/// event finds no rows and is a no-op. +/// +/// Opens scopes for the scoped storage-monitor store. +/// Resolves the #storagemonitors channel id. +/// Deletes the storage-monitor embeds. +/// The logger. +internal sealed partial class StorageMonitorWipePurger( + IServiceScopeFactory scopeFactory, + IStorageMonitorChannelLocator locator, + IStorageMonitorChannelPoster poster, + ILogger logger) +{ + /// Handles one by purging the server's storage monitors. + /// The wipe event. + /// A cancellation token. + /// A task that completes when rows and embeds have been removed. + public async Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(evt); + IReadOnlyList monitors; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + monitors = await store.ListByServerAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + foreach (var monitor in monitors) + { + await store.RemoveAsync(evt.GuildId, evt.ServerId, monitor.EntityId, ct).ConfigureAwait(false); + } + } + + if (monitors.Count == 0) + { + return; + } + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + if (channelId is { } cid) + { + foreach (var monitor in monitors) + { + if (monitor.MessageId is { } messageId) + { + await poster.DeleteMessageAsync(cid, messageId, ct).ConfigureAwait(false); + } + } + } + + LogPurged(logger, monitors.Count, evt.GuildId, evt.ServerId); + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "Purged {Count} storage monitors after wipe for guild {GuildId} server {ServerId}.")] + private static partial void LogPurged(ILogger logger, int count, ulong guildId, Guid serverId); +} +``` + +In `src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs`: add constructor param `StorageMonitorWipePurger purger` (update `` docs), field `private Task? _wipedLoop;`, start `_wipedLoop = Task.Run(() => ConsumeWipedAsync(_cts.Token), CancellationToken.None);` in `StartAsync`, add `_wipedLoop` to the `StopAsync` join array, and add the same `ConsumeWipedAsync` consumer shown in full in Task 8 Step 3, with the log message: + +```csharp + [LoggerMessage(Level = LogLevel.Error, Message = "Storage-monitor wipe-purge loop faulted.")] + private static partial void LogWipedLoopFaulted(ILogger logger, Exception exception); +``` + +In `src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs`, add `services.AddSingleton();`. + +Update every `new StorageMonitorsHostedService(` construction site in `tests/RustPlusBot.Features.StorageMonitors.Tests` (grep for it): build a purger from the harness's existing scope factory + locator/poster substitutes — + +```csharp + var purger = new StorageMonitorWipePurger(scopeFactory, locator, poster, + NullLogger.Instance); +``` + +(adjust the variable names to that file's existing substitutes) and pass it in the new constructor position. + +- [ ] **Step 4: Run the tests** + +Run: `dotnet test tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj -maxcpucount:1` +Expected: PASS. + +- [ ] **Step 5: Cleanup + commit** + +```bash +dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx +git add -A +git commit -m "Purge storage monitors on server wipe + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 10: @everyone-on-wipe toggle in #settings + +A toggle button under the language selector; state persisted via the Task 2 accessors; workspace re-render on toggle. + +**Files:** +- Modify: `src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs` +- Modify: `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs` (update `SettingsMessageRenderer` construction sites) +- Test: `tests/RustPlusBot.Features.Workspace.Tests/Messages/SettingsMessageRendererWipePingTests.cs` (create) + +**Interfaces:** +- Consumes: `IWorkspaceStore.GetPingEveryoneOnWipeAsync/SetPingEveryoneOnWipeAsync` (Task 2), `settings.wipeping.on/off` resx keys (Task 4). +- Produces: `SettingsMessageRenderer.WipePingButtonId` (`"workspace:settings:wipeping"`) — nothing else consumes it beyond the module. + +- [ ] **Step 1: Write the failing renderer tests** + +Create `tests/RustPlusBot.Features.Workspace.Tests/Messages/SettingsMessageRendererWipePingTests.cs`: + +```csharp +using Discord; +using NSubstitute; +using RustPlusBot.Features.Workspace.Messages; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Tests.Messages; + +/// Wipe-ping toggle tests for . +public sealed class SettingsMessageRendererWipePingTests +{ + private static SettingsMessageRenderer Create(bool ping) + { + var store = Substitute.For(); + store.GetPingEveryoneOnWipeAsync(Arg.Any(), Arg.Any()).Returns(ping); + return new SettingsMessageRenderer(store, new ResxLocalizer()); + } + + private static ButtonComponent SingleButton(MessagePayload payload) => + payload.Components!.Components + .SelectMany(row => row.Components) + .OfType() + .Single(); + + [Fact] + public async Task Renders_off_button_by_default() + { + var renderer = Create(ping: false); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1UL, null, "en"), CancellationToken.None); + + var button = SingleButton(payload); + Assert.Equal(SettingsMessageRenderer.WipePingButtonId, button.CustomId); + Assert.Equal(ButtonStyle.Secondary, button.Style); + Assert.Equal("🔕 Ping @everyone on wipe: Off", button.Label); + } + + [Fact] + public async Task Renders_on_button_when_enabled() + { + var renderer = Create(ping: true); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1UL, null, "en"), CancellationToken.None); + + var button = SingleButton(payload); + Assert.Equal(ButtonStyle.Success, button.Style); + Assert.Equal("🔔 Ping @everyone on wipe: On", button.Label); + } + + [Fact] + public async Task Language_select_menu_is_still_present() + { + var renderer = Create(ping: false); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1UL, null, "en"), CancellationToken.None); + + var menu = payload.Components!.Components + .SelectMany(row => row.Components) + .OfType() + .Single(); + Assert.Equal(SettingsMessageRenderer.LanguageSelectId, menu.CustomId); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj -maxcpucount:1 --filter SettingsMessageRendererWipePingTests` +Expected: FAIL — constructor mismatch / `WipePingButtonId` missing. + +- [ ] **Step 3: Implement** + +Rewrite `src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs` as: + +```csharp +using Discord; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Messages; + +/// Renders the global #settings message with the language selector and the wipe-ping toggle. +/// Reads the guild's wipe-ping flag. +/// String resolution. +internal sealed class SettingsMessageRenderer(IWorkspaceStore store, ILocalizer localizer) : IMessageRenderer +{ + /// Custom id of the language select menu, handled by the settings component module. + public const string LanguageSelectId = "workspace:settings:culture"; + + /// Custom id of the @everyone-on-wipe toggle button, handled by the settings component module. + public const string WipePingButtonId = "workspace:settings:wipeping"; + + /// + public string MessageKey => WorkspaceMessageKeys.SettingsMain; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var ping = await store.GetPingEveryoneOnWipeAsync(context.GuildId, cancellationToken).ConfigureAwait(false); + + 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 wipePing = new ButtonBuilder() + .WithCustomId(WipePingButtonId) + .WithLabel(localizer.Get(ping ? "settings.wipeping.on" : "settings.wipeping.off", context.Culture)) + .WithStyle(ping ? ButtonStyle.Success : ButtonStyle.Secondary); + var components = new ComponentBuilder() + .WithSelectMenu(menu) + .WithButton(wipePing, row: 1) + .Build(); + + return new MessagePayload(null, embed, components); + } +} +``` + +In `src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs`, add after `SetCultureAsync`: + +```csharp + /// Toggles the @everyone-on-wipe ping and re-renders the workspace. + [ComponentInteraction(SettingsMessageRenderer.WipePingButtonId)] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task ToggleWipePingAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + try + { + var store = scope.ServiceProvider.GetRequiredService(); + var enabled = !await store.GetPingEveryoneOnWipeAsync(Context.Guild.Id).ConfigureAwait(false); + await store.SetPingEveryoneOnWipeAsync(Context.Guild.Id, enabled).ConfigureAwait(false); + + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.ReconcileGlobalAsync(Context.Guild.Id).ConfigureAwait(false); + + await FollowupAsync($"@everyone ping on wipe {(enabled ? "enabled" : "disabled")}.", ephemeral: true) + .ConfigureAwait(false); + } + finally + { + await scope.DisposeAsync().ConfigureAwait(false); + } + } +``` + +Update the existing construction sites: grep for `new SettingsMessageRenderer(` under `tests/RustPlusBot.Features.Workspace.Tests` (expect hits in `Messages/RendererTests.cs` and possibly the reconciler tests) and prepend a store argument: + +```csharp + var settingsStore = Substitute.For(); + settingsStore.GetPingEveryoneOnWipeAsync(Arg.Any(), Arg.Any()).Returns(false); + // then: new SettingsMessageRenderer(settingsStore, ) +``` + +(If a test file constructs it inside a DI container instead, register the substitute as `services.AddScoped(_ => settingsStore)` — match whatever that file already does for other renderers' stores.) + +- [ ] **Step 4: Run the tests** + +Run: `dotnet test tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj -maxcpucount:1` +Expected: PASS (3 new + all existing workspace tests). + +- [ ] **Step 5: Cleanup + commit** + +```bash +dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx +git add -A +git commit -m "Add @everyone-on-wipe toggle to the settings channel + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 11: Full-solution verification + +**Files:** none (verification only; commit only if cleanup produces diffs). + +- [ ] **Step 1: Full build + full test run** + +```bash +dotnet build RustPlusBot.slnx -maxcpucount:1 +dotnet test RustPlusBot.slnx -maxcpucount:1 +``` + +Expected: build clean; ALL test assemblies report non-zero counts (read per-assembly totals — a dropped assembly reports 0 and masquerades as green). `RustPlusBot.Features.Wipes.Tests` must appear with ~22 tests. + +- [ ] **Step 2: Migration drift check** + +Run: `dotnet ef migrations has-pending-model-changes --project src/RustPlusBot.Persistence --startup-project src/RustPlusBot.Host` +Expected: "No changes have been made to the model since the last migration." + +- [ ] **Step 3: Format gate** + +Run: `dotnet jb cleanupcode --profile=ReformatAndReorder RustPlusBot.slnx` then `git status --short` +Expected: no modified files. If there are diffs, commit them as `Apply code cleanup` (with the co-author trailer). + +- [ ] **Step 4: Verify the feature end-to-end (manual smoke, optional but recommended)** + +If a dev bot token + test guild are configured (see `docs/development/running-locally.md`): run the Host, pair a test server, confirm `#settings` shows the new toggle button and clicking it flips the label; flip `LastMapSeed` in the DB (`sqlite3 "UPDATE RustServers SET LastMapSeed = 1"`) and restart the connection to watch the wipe announcement land in #events and the device embeds disappear. Otherwise rely on the unit suites. + +- [ ] **Step 5: Hand off** + +Implementation complete on `feat/wipe-detection`. Use superpowers:finishing-a-development-branch to merge or open a PR to `develop`. diff --git a/docs/superpowers/specs/2026-07-15-wipe-detection-design.md b/docs/superpowers/specs/2026-07-15-wipe-detection-design.md new file mode 100644 index 00000000..b6bf2396 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-wipe-detection-design.md @@ -0,0 +1,196 @@ +# Server Wipe Detection — Design + +**Date:** 2026-07-15 +**Status:** Approved (brainstorm gate passed) +**Branch (planned):** feat/wipe-detection off develop + +## Problem + +When the Rust server wipes (forced/map wipe), the world is reset: every smart +device (switch, alarm, storage monitor) is destroyed in game and its entity id +becomes permanently invalid. Today the bot is blind to this: + +- Device rows and their embeds stay in #switches/#alarms/#storagemonitors + forever, pointing at entities that no longer exist. +- Nobody is told the server wiped; the team discovers it by joining. +- Stale alarms linger as dead entries (they can never trigger again, but they + clutter the channel and look "armed"). + +`ServerInfoSnapshot.WipeTimeUtc` (from `getInfo`) and `WorldSnapshot.Seed` / +`WorldSize` (from `getInfo` via `GetWorldAsync`) already expose the signals — +but nothing persists a baseline to diff against, so a wipe is invisible. + +**Goal:** detect the wipe when the server comes back up, announce it in the +per-server **#events** channel (optionally pinging @everyone via a global +setting), and purge all paired devices so no stale state or notifications +survive the wipe. + +## Decisions (user-confirmed) + +1. **Auto-delete all devices on wipe.** Entity ids are permanently invalid + after a wipe, so switches/alarms/storage monitors (and generic + `PairedEntity` rows) are deleted outright — DB rows and Discord embeds. + Users re-pair on the new map. No confirmation prompt, no "wiped" tombstone + state. +2. **Announcement goes to the per-server #events channel**, alongside the + existing cargo/heli/rig event posts. +3. **@everyone ping is a global guild setting** (`PingEveryoneOnWipe`) surfaced + as a toggle in the **#settings** message, **default OFF**. +4. **Detection is event-driven, not supervisor-inline** (approach B below): + a new `Features.Wipes` project reacts to the existing + `ConnectionStatusChangedEvent`; `ConnectionSupervisor` is untouched. + +## Detection model + +A wipe always implies a server restart, which the bot always observes as +disconnect → reconnect (`ConnectionStatusChangedEvent` with +`IsConnected && !WasConnected`). Checking on that transition is sufficient — +no polling needed. Because the baseline is persisted, a wipe that happens +while the **bot** is down is still caught on the next connect. + +Approaches considered: + +- **A — inline in `ConnectionSupervisor`:** rejected; fattens an already-large + class and couples it to wipe semantics. +- **B — event-driven feature (chosen):** mirrors how Alarms/Switches/Events + already consume connection events; fully testable in isolation. +- **C — periodic poll loop:** rejected; wipes cannot happen without a restart, + so connect-time checks cover every case at zero steady-state cost. + +### Baseline persistence + +Three nullable columns added to `RustServer` (`Domain/Servers/RustServer.cs`): + +| Column | Type | Source | +| ----------------- | ----------------- | ---------------------------------- | +| `LastWipeTimeUtc` | `DateTimeOffset?` | `ServerInfoSnapshot.WipeTimeUtc` | +| `LastMapSeed` | `uint?` | `WorldSnapshot.Seed` | +| `LastMapSize` | `uint?` | `WorldSnapshot.WorldSize` | + +Plus `GuildSettings.PingEveryoneOnWipe` (`bool`, default `false`). One EF +migration (`WipeDetection`) covers all four. + +### Diff rules (WipeDetector) + +On each transition to connected, query `IRustServerQuery.GetServerInfoAsync` +and `GetWorldAsync` (either returning null — socket dropped mid-check — aborts +silently; the next reconnect retries): + +1. **Empty baseline** (all three columns null — first connect ever, or first + connect after this feature deploys): store the observed values silently. + No event. This prevents a false wipe announcement on upgrade. +2. **Wiped** when any of: + - `newWipeTime > LastWipeTimeUtc + 60s` (tolerance absorbs clock jitter), + - `Seed != LastMapSeed`, + - `WorldSize != LastMapSize`. + Null observed values never match a rule (skipped, not treated as change). +3. On wipe: persist the new baseline **first**, then publish + `ServerWipedEvent` (consistent with persist-then-publish elsewhere; the + in-process bus makes the crash window negligible, and purge is idempotent). +4. Otherwise: no-op (plain reconnect). + +## Architecture + +### New event (Abstractions/Events) + +```csharp +public sealed record ServerWipedEvent( + ulong GuildId, + Guid ServerId, + DateTimeOffset? PreviousWipeTimeUtc, + DateTimeOffset? NewWipeTimeUtc, + uint Seed, + uint WorldSize); +``` + +### New project: `RustPlusBot.Features.Wipes` (+ mirrored test project) + +- `Hosting/WipesHostedService` — subscribes to `ConnectionStatusChangedEvent` + (connected transitions → detector) and to `ServerWipedEvent` (→ announcer). +- `Detection/WipeDetector` (`IWipeDetector`) — the diff rules above. +- `Posting/DiscordWipeChannelPoster` (`IWipeChannelPoster`) — posts to + #events via the existing `IEventChannelLocator` (Features.Workspace) and + `DiscordChannelMessenger` pattern; reads `PingEveryoneOnWipe` to decide + whether the message content carries `@everyone`. +- `Rendering/WipeEmbedRenderer` — the announcement embed: title + ("🧹 Server wiped"), wiped-at as a Discord relative timestamp, new map size + and seed fields, and a line stating all paired devices were removed and must + be re-paired in game. Localized (en/fr resx keys under `wipe.*`). + +### Persistence + +- `Persistence/Wipes/WipeBaselineStore` (`IWipeBaselineStore`) — reads/updates + the three `RustServer` baseline columns (single-purpose store, keeps + `ServerService` focused). +- `PairedEntity` store gains `RemoveByServerAsync(guildId, serverId)`. +- Device stores already expose `ListByServerAsync` + `RemoveAsync` — the wipe + purge is their first caller outside pairing flows. +- `GuildSettings` store gains the `PingEveryoneOnWipe` accessor/mutator beside + the existing culture handling. + +### Purge fan-out (modular ownership) + +Each feature cleans up its own state by subscribing to `ServerWipedEvent` in +its existing hosted service: + +- **Features.Alarms / Switches / StorageMonitors** — list rows for the wiped + server; for each, delete the Discord embed + (`IWorkspaceGateway.DeleteMessageAsync` via the feature's channel locator, + missing-message tolerant) then `RemoveAsync` the row. Deleting rows is also + the stale-notification guarantee: a late `SmartDeviceTriggeredEvent` for a + dead entity finds no row and is dropped (existing relay behavior — verify + and cover with a test). +- **Features.Pairing** — `RemoveByServerAsync` on `PairedEntity` rows. +- **Features.Map / Events** — nothing: RustMaps generation keys on + `(size, seed)`, so the new seed already produces a fresh map; in-memory + event/rig state is already cleared on disconnect. + +Purge handlers are idempotent (re-running on an already-purged server is a +no-op), so a duplicate `ServerWipedEvent` is harmless. + +### Settings toggle (Features.Workspace) + +- `SettingsMessageRenderer` adds a toggle button + (`workspace:settings:wipeping`) under the language selector, label showing + the current state (e.g. "🔔 Ping @everyone on wipe: Off"). +- The settings component module (the one handling `LanguageSelectId`) gains a + handler that flips `GuildSettings.PingEveryoneOnWipe` and re-renders the + message. + +### Ordering note + +`ConnectionSupervisor.PrimeDevicesAsync` may prime dead entity ids on the +first post-wipe connect before the purge lands — harmless: dead entities never +broadcast, and priming publishes only no-ping `SmartDeviceStateObservedEvent`s +which drop once the rows are gone. + +## Edge cases + +- **Bot offline during the wipe:** baseline is persisted → caught on next + connect. +- **Existing deployments:** first post-deploy connect backfills the baseline + silently (rule 1). +- **Reconnect without wipe** (network blip, bot restart): all values match → + no-op. +- **Server rotates IP/endpoint for the new wipe:** out of scope — that arrives + as a new server pairing, already handled by the pairing confirmation flow. +- **Wipe announcement channel missing** (user deleted #events): poster logs + and skips, same tolerance as existing event posting. + +## Testing + +- `Features.Wipes.Tests` — detector: empty-baseline backfill, wipe-time + advance beyond/within tolerance, seed change, size change, null snapshots + abort, persist-then-publish order; renderer output; poster ping on/off from + the guild setting. +- Device feature test projects — purge removes rows + messages, idempotency, + late trigger for a purged entity is dropped. +- Workspace tests — settings toggle round-trip and renderer state. + +## Non-goals + +- Predicting or scheduling upcoming wipes (no forced-wipe calendar). +- Automatic device re-pairing after a wipe. +- Per-server (rather than per-guild) ping configuration. +- Blueprint-wipe detection distinct from map wipe (a BP-only wipe without a + map change and without `WipeTime` moving is indistinguishable and ignored). diff --git a/src/RustPlusBot.Abstractions/Events/ServerWipedEvent.cs b/src/RustPlusBot.Abstractions/Events/ServerWipedEvent.cs new file mode 100644 index 00000000..8c82e4f1 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/ServerWipedEvent.cs @@ -0,0 +1,16 @@ +namespace RustPlusBot.Abstractions.Events; + +/// Published when a reconnected server's wipe baseline no longer matches (the server wiped while we were away or restarting). +/// The owning guild snowflake. +/// The wiped server id. +/// The baseline wipe time before this wipe, or null if never observed. +/// The freshly observed wipe time, or null when the server does not report one. +/// The new procedural map seed. +/// The new world size (game units). +public sealed record ServerWipedEvent( + ulong GuildId, + Guid ServerId, + DateTimeOffset? PreviousWipeTimeUtc, + DateTimeOffset? NewWipeTimeUtc, + uint Seed, + uint WorldSize); diff --git a/src/RustPlusBot.Domain/Guilds/GuildSettings.cs b/src/RustPlusBot.Domain/Guilds/GuildSettings.cs index fb82cc01..d69200cb 100644 --- a/src/RustPlusBot.Domain/Guilds/GuildSettings.cs +++ b/src/RustPlusBot.Domain/Guilds/GuildSettings.cs @@ -8,4 +8,7 @@ public sealed class GuildSettings /// BCP-47 culture for localized output (e.g. "en", "fr"). public string Culture { get; set; } = "en"; + + /// When true, the server-wiped announcement pings @everyone in #events. Off by default. + public bool PingEveryoneOnWipe { get; set; } } diff --git a/src/RustPlusBot.Domain/Servers/RustServer.cs b/src/RustPlusBot.Domain/Servers/RustServer.cs index 084f23d5..197dcc8c 100644 --- a/src/RustPlusBot.Domain/Servers/RustServer.cs +++ b/src/RustPlusBot.Domain/Servers/RustServer.cs @@ -23,4 +23,13 @@ public sealed class RustServer /// The Facepunch server GUID from FCM pairings, backfilled on server pairing; null until first seen. Used to attribute entity pairings. public Guid? FacepunchServerId { get; set; } + + /// Baseline: the last observed wipe time (UTC) from getInfo, or null before first observation. + public DateTimeOffset? LastWipeTimeUtc { get; set; } + + /// Baseline: the last observed procedural map seed, or null before first observation. + public uint? LastMapSeed { get; set; } + + /// Baseline: the last observed world size (game units), or null before first observation. + public uint? LastMapSize { get; set; } } diff --git a/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs index 5cc6806f..97eaf72a 100644 --- a/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs @@ -12,7 +12,7 @@ namespace RustPlusBot.Features.Alarms; /// DI registration for the Smart Alarms feature. public static class AlarmServiceCollectionExtensions { - /// Registers the localizer, renderer, poster, refresher, coordinator, relay, modules, and hosted service. + /// Registers the localizer, renderer, poster, refresher, coordinator, relay, wipe purger, modules, and hosted service. /// The service collection to add to. /// The same service collection, for chaining. public static IServiceCollection AddAlarms(this IServiceCollection services) @@ -26,6 +26,7 @@ public static IServiceCollection AddAlarms(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); // Contribute this assembly's interaction modules to the Discord layer. diff --git a/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs b/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs index fbb0c640..15fc6629 100644 --- a/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs +++ b/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs @@ -6,15 +6,17 @@ namespace RustPlusBot.Features.Alarms.Hosting; -/// Runs the alarm-pairing loop, the alarm-triggered relay loop, the connection-status relay loop, the per-device reachability loop, and the observed-state sync loop. +/// Runs the alarm-pairing loop, the alarm-triggered relay loop, the connection-status relay loop, the per-device reachability loop, the observed-state sync loop, and the wipe-purge loop. /// The in-process event bus. /// Handles paired alarms. /// Re-renders alarms on trigger/connection/reachability/observed-state changes. +/// Purges alarms when a server wipes. /// The logger. internal sealed partial class AlarmsHostedService( IEventBus eventBus, AlarmPairingCoordinator coordinator, AlarmStateRelay relay, + AlarmWipePurger purger, ILogger logger) : IHostedService, IDisposable { private readonly CancellationTokenSource _cts = new(); @@ -23,6 +25,7 @@ internal sealed partial class AlarmsHostedService( private Task? _reachabilityLoop; private Task? _statusLoop; private Task? _triggeredLoop; + private Task? _wipedLoop; /// public void Dispose() => _cts.Dispose(); @@ -35,6 +38,7 @@ public Task StartAsync(CancellationToken cancellationToken) _statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None); _reachabilityLoop = Task.Run(() => ConsumeReachabilityChangedAsync(_cts.Token), CancellationToken.None); _observedLoop = Task.Run(() => ConsumeStateObservedAsync(_cts.Token), CancellationToken.None); + _wipedLoop = Task.Run(() => ConsumeWipedAsync(_cts.Token), CancellationToken.None); return Task.CompletedTask; } @@ -44,7 +48,7 @@ public async Task StopAsync(CancellationToken cancellationToken) await _cts.CancelAsync().ConfigureAwait(false); foreach (var loop in new[] { - _pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop, _observedLoop + _pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop, _observedLoop, _wipedLoop }.Where(t => t is not null)) { try @@ -170,6 +174,28 @@ private async Task ConsumeStateObservedAsync(CancellationToken cancellationToken } } + private async Task ConsumeWipedAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await purger.HandleServerWipedAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogWipedLoopFaulted(logger, ex); + } + } + [LoggerMessage(Level = LogLevel.Error, Message = "Alarm pairing loop faulted.")] private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception); @@ -184,4 +210,7 @@ private async Task ConsumeStateObservedAsync(CancellationToken cancellationToken [LoggerMessage(Level = LogLevel.Error, Message = "Alarm observed-state sync loop faulted.")] private static partial void LogObservedLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Alarm wipe-purge loop faulted.")] + private static partial void LogWipedLoopFaulted(ILogger logger, Exception exception); } diff --git a/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs b/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs index 603fddf2..50696aa0 100644 --- a/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs +++ b/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs @@ -56,7 +56,43 @@ await channel.SendMessageAsync( } } + /// + public async Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken) + { + try + { + var options = new RequestOptions + { + CancelToken = cancellationToken + }; + if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) + is not ITextChannel channel) + { + return; + } + + await channel.DeleteMessageAsync(messageId, options).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Shutdown — let the loop unwind. + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup (or already-deleted message) must not crash the purge. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDeleteFailed(logger, ex, messageId, channelId); + } + } + [LoggerMessage(Level = LogLevel.Warning, Message = "Sending @everyone ping in channel {ChannelId} failed.")] private static partial void LogPingFailed(ILogger logger, Exception exception, ulong channelId); + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Deleting message {MessageId} in channel {ChannelId} failed (may already be gone).")] + private static partial void LogDeleteFailed(ILogger logger, + Exception exception, + ulong messageId, + ulong channelId); } diff --git a/src/RustPlusBot.Features.Alarms/Posting/IAlarmChannelPoster.cs b/src/RustPlusBot.Features.Alarms/Posting/IAlarmChannelPoster.cs index be01e3b8..47ec8311 100644 --- a/src/RustPlusBot.Features.Alarms/Posting/IAlarmChannelPoster.cs +++ b/src/RustPlusBot.Features.Alarms/Posting/IAlarmChannelPoster.cs @@ -23,4 +23,11 @@ internal interface IAlarmChannelPoster /// A cancellation token. /// A task that completes when the message has been sent (or silently swallowed on failure). Task SendEveryonePingAsync(ulong channelId, string content, CancellationToken cancellationToken); + + /// Deletes a message in the given channel (missing message/channel tolerated). + /// The #alarms channel id. + /// The message id to delete. + /// A cancellation token. + /// A task that completes when the message has been deleted (or the failure swallowed). + Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken); } diff --git a/src/RustPlusBot.Features.Alarms/Relaying/AlarmWipePurger.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmWipePurger.cs new file mode 100644 index 00000000..df3e4834 --- /dev/null +++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmWipePurger.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Alarms; +using RustPlusBot.Features.Alarms.Posting; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Alarms; + +namespace RustPlusBot.Features.Alarms.Relaying; + +/// +/// Deletes all of a wiped server's alarms: entity ids are permanently invalid after a wipe, so the rows +/// are removed first (guaranteeing no stale trigger notifications), then the #alarms embeds best-effort. +/// Idempotent: a duplicate wipe event finds no rows and is a no-op. +/// +/// Opens scopes for the scoped alarm store. +/// Resolves the #alarms channel id. +/// Deletes the alarm embeds. +/// The logger. +internal sealed partial class AlarmWipePurger( + IServiceScopeFactory scopeFactory, + IAlarmChannelLocator locator, + IAlarmChannelPoster poster, + ILogger logger) +{ + /// Handles one by purging the server's alarms. + /// The wipe event. + /// A cancellation token. + /// A task that completes when rows and embeds have been removed. + public async Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(evt); + IReadOnlyList alarms; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + alarms = await store.ListByServerAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + foreach (var alarm in alarms) + { + await store.RemoveAsync(evt.GuildId, evt.ServerId, alarm.EntityId, ct).ConfigureAwait(false); + } + } + + if (alarms.Count == 0) + { + return; + } + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + if (channelId is { } cid) + { + foreach (var alarm in alarms) + { + if (alarm.MessageId is { } messageId) + { + await poster.DeleteMessageAsync(cid, messageId, ct).ConfigureAwait(false); + } + } + } + + LogPurged(logger, alarms.Count, evt.GuildId, evt.ServerId); + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "Purged {Count} alarms after wipe for guild {GuildId} server {ServerId}.")] + private static partial void LogPurged(ILogger logger, int count, ulong guildId, Guid serverId); +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs b/src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs index 6be7dd2d..ea8d21ca 100644 --- a/src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs +++ b/src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs @@ -6,15 +6,17 @@ namespace RustPlusBot.Features.StorageMonitors.Hosting; -/// Runs the storage-monitor pairing loop, the triggered relay loop, the connection-status relay loop, and the reachability relay loop. +/// Runs the storage-monitor pairing loop, the triggered relay loop, the connection-status relay loop, the reachability relay loop, and the wipe-purge loop. /// The in-process event bus. /// Handles paired storage monitors. /// Re-renders storage monitors on trigger/connection changes. +/// Purges storage monitors when a server wipes. /// The logger. internal sealed partial class StorageMonitorsHostedService( IEventBus eventBus, StorageMonitorPairingCoordinator coordinator, StorageMonitorStateRelay relay, + StorageMonitorWipePurger purger, ILogger logger) : IHostedService, IDisposable { private readonly CancellationTokenSource _cts = new(); @@ -22,6 +24,7 @@ internal sealed partial class StorageMonitorsHostedService( private Task? _reachabilityLoop; private Task? _statusLoop; private Task? _triggeredLoop; + private Task? _wipedLoop; /// public void Dispose() => _cts.Dispose(); @@ -33,6 +36,7 @@ public Task StartAsync(CancellationToken cancellationToken) _triggeredLoop = Task.Run(() => ConsumeTriggeredAsync(_cts.Token), CancellationToken.None); _statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None); _reachabilityLoop = Task.Run(() => ConsumeReachabilityChangedAsync(_cts.Token), CancellationToken.None); + _wipedLoop = Task.Run(() => ConsumeWipedAsync(_cts.Token), CancellationToken.None); return Task.CompletedTask; } @@ -42,7 +46,7 @@ public async Task StopAsync(CancellationToken cancellationToken) await _cts.CancelAsync().ConfigureAwait(false); foreach (var loop in new[] { - _pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop + _pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop, _wipedLoop }.Where(t => t is not null)) { try @@ -146,6 +150,28 @@ private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellatio } } + private async Task ConsumeWipedAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await purger.HandleServerWipedAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogWipedLoopFaulted(logger, ex); + } + } + [LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor pairing loop faulted.")] private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception); @@ -157,4 +183,7 @@ private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellatio [LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor reachability relay loop faulted.")] private static partial void LogReachabilityLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Storage-monitor wipe-purge loop faulted.")] + private static partial void LogWipedLoopFaulted(ILogger logger, Exception exception); } diff --git a/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs b/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs index 3c809ce0..6bb499ae 100644 --- a/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs +++ b/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs @@ -1,13 +1,16 @@ using Discord; +using Discord.WebSocket; using Microsoft.Extensions.Logging; using RustPlusBot.Discord.Posting; namespace RustPlusBot.Features.StorageMonitors.Posting; /// Posts/edits storage monitor embeds in #storagemonitors by message id. Untested integration shim. +/// The Discord socket client (used directly for raw message deletes). /// The shared gated channel messenger. /// The logger. -internal sealed class DiscordStorageMonitorChannelPoster( +internal sealed partial class DiscordStorageMonitorChannelPoster( + DiscordSocketClient client, DiscordChannelMessenger messenger, ILogger logger) : IStorageMonitorChannelPoster { @@ -19,4 +22,40 @@ internal sealed class DiscordStorageMonitorChannelPoster( MessageComponent components, CancellationToken cancellationToken) => messenger.EnsureAsync(channelId, messageId, embed, components, logger, cancellationToken); + + /// + public async Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken) + { + try + { + var options = new RequestOptions + { + CancelToken = cancellationToken + }; + if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) + is not ITextChannel channel) + { + return; + } + + await channel.DeleteMessageAsync(messageId, options).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Shutdown — let the loop unwind. + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup (or already-deleted message) must not crash the purge. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDeleteFailed(logger, ex, messageId, channelId); + } + } + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Deleting message {MessageId} in channel {ChannelId} failed (may already be gone).")] + private static partial void LogDeleteFailed(ILogger logger, + Exception exception, + ulong messageId, + ulong channelId); } diff --git a/src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs b/src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs index 399a1e1f..10fcffe5 100644 --- a/src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs +++ b/src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs @@ -16,4 +16,11 @@ internal interface IStorageMonitorChannelPoster global::Discord.Embed embed, global::Discord.MessageComponent components, CancellationToken cancellationToken); + + /// Deletes a message in the given channel (missing message/channel tolerated). + /// The #storagemonitors channel id. + /// The message id to delete. + /// A cancellation token. + /// A task that completes when the message has been deleted (or the failure swallowed). + Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken); } diff --git a/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorWipePurger.cs b/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorWipePurger.cs new file mode 100644 index 00000000..e4952862 --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorWipePurger.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.StorageMonitors; +using RustPlusBot.Features.StorageMonitors.Posting; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.StorageMonitors; + +namespace RustPlusBot.Features.StorageMonitors.Relaying; + +/// +/// Deletes all of a wiped server's storage monitors: entity ids are permanently invalid after a wipe, so +/// the rows are removed first, then the #storagemonitors embeds best-effort. Idempotent: a duplicate wipe +/// event finds no rows and is a no-op. +/// +/// Opens scopes for the scoped storage-monitor store. +/// Resolves the #storagemonitors channel id. +/// Deletes the storage-monitor embeds. +/// The logger. +internal sealed partial class StorageMonitorWipePurger( + IServiceScopeFactory scopeFactory, + IStorageMonitorChannelLocator locator, + IStorageMonitorChannelPoster poster, + ILogger logger) +{ + /// Handles one by purging the server's storage monitors. + /// The wipe event. + /// A cancellation token. + /// A task that completes when rows and embeds have been removed. + public async Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(evt); + IReadOnlyList monitors; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + monitors = await store.ListByServerAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + foreach (var monitor in monitors) + { + await store.RemoveAsync(evt.GuildId, evt.ServerId, monitor.EntityId, ct).ConfigureAwait(false); + } + } + + if (monitors.Count == 0) + { + return; + } + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + if (channelId is { } cid) + { + foreach (var monitor in monitors) + { + if (monitor.MessageId is { } messageId) + { + await poster.DeleteMessageAsync(cid, messageId, ct).ConfigureAwait(false); + } + } + } + + LogPurged(logger, monitors.Count, evt.GuildId, evt.ServerId); + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "Purged {Count} storage monitors after wipe for guild {GuildId} server {ServerId}.")] + private static partial void LogPurged(ILogger logger, int count, ulong guildId, Guid serverId); +} diff --git a/src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs b/src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs index 65fa0650..91d7447c 100644 --- a/src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs @@ -26,6 +26,7 @@ public static IServiceCollection AddStorageMonitors(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); services.AddSingleton(new InteractionModuleAssembly( diff --git a/src/RustPlusBot.Features.Switches/Hosting/SwitchesHostedService.cs b/src/RustPlusBot.Features.Switches/Hosting/SwitchesHostedService.cs index 6db9112c..0ec822bb 100644 --- a/src/RustPlusBot.Features.Switches/Hosting/SwitchesHostedService.cs +++ b/src/RustPlusBot.Features.Switches/Hosting/SwitchesHostedService.cs @@ -6,15 +6,17 @@ namespace RustPlusBot.Features.Switches.Hosting; -/// Runs the switch-pairing loop and the switch-state/connection-status relay loop. +/// Runs the switch-pairing loop, the switch-state/connection-status relay loop, and the wipe-purge loop. /// The in-process event bus. /// Handles paired switches. /// Re-renders switches on state/connection changes. +/// Purges switches when a server wipes. /// The logger. internal sealed partial class SwitchesHostedService( IEventBus eventBus, SwitchPairingCoordinator coordinator, SwitchStateRelay relay, + SwitchWipePurger purger, ILogger logger) : IHostedService, IDisposable { private readonly CancellationTokenSource _cts = new(); @@ -23,6 +25,7 @@ internal sealed partial class SwitchesHostedService( private Task? _reachabilityLoop; private Task? _stateLoop; private Task? _statusLoop; + private Task? _wipedLoop; /// public void Dispose() => _cts.Dispose(); @@ -35,6 +38,7 @@ public Task StartAsync(CancellationToken cancellationToken) _statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None); _deviceLoop = Task.Run(() => ConsumeDeviceTriggeredAsync(_cts.Token), CancellationToken.None); _reachabilityLoop = Task.Run(() => ConsumeReachabilityChangedAsync(_cts.Token), CancellationToken.None); + _wipedLoop = Task.Run(() => ConsumeWipedAsync(_cts.Token), CancellationToken.None); return Task.CompletedTask; } @@ -44,7 +48,7 @@ public async Task StopAsync(CancellationToken cancellationToken) await _cts.CancelAsync().ConfigureAwait(false); foreach (var loop in new[] { - _pairedLoop, _stateLoop, _statusLoop, _deviceLoop, _reachabilityLoop + _pairedLoop, _stateLoop, _statusLoop, _deviceLoop, _reachabilityLoop, _wipedLoop }.Where(t => t is not null)) { try @@ -170,6 +174,28 @@ private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellatio } } + private async Task ConsumeWipedAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await purger.HandleServerWipedAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogWipedLoopFaulted(logger, ex); + } + } + [LoggerMessage(Level = LogLevel.Error, Message = "Switch device-triggered relay loop faulted.")] private static partial void LogDeviceLoopFaulted(ILogger logger, Exception exception); @@ -184,4 +210,7 @@ private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellatio [LoggerMessage(Level = LogLevel.Error, Message = "Switch reachability relay loop faulted.")] private static partial void LogReachabilityLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Switch wipe-purge loop faulted.")] + private static partial void LogWipedLoopFaulted(ILogger logger, Exception exception); } diff --git a/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs b/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs index 7a67d38d..078b2d8b 100644 --- a/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs +++ b/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs @@ -1,13 +1,16 @@ using Discord; +using Discord.WebSocket; using Microsoft.Extensions.Logging; using RustPlusBot.Discord.Posting; namespace RustPlusBot.Features.Switches.Posting; /// Posts/edits switch embeds in #switches by message id. Untested integration shim. +/// The Discord socket client (used directly for raw message deletes). /// The shared gated channel messenger. /// The logger. -internal sealed class DiscordSwitchChannelPoster( +internal sealed partial class DiscordSwitchChannelPoster( + DiscordSocketClient client, DiscordChannelMessenger messenger, ILogger logger) : ISwitchChannelPoster { @@ -19,4 +22,40 @@ internal sealed class DiscordSwitchChannelPoster( MessageComponent components, CancellationToken cancellationToken) => messenger.EnsureAsync(channelId, messageId, embed, components, logger, cancellationToken); + + /// + public async Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken) + { + try + { + var options = new RequestOptions + { + CancelToken = cancellationToken + }; + if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) + is not ITextChannel channel) + { + return; + } + + await channel.DeleteMessageAsync(messageId, options).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Shutdown — let the loop unwind. + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup (or already-deleted message) must not crash the purge. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDeleteFailed(logger, ex, messageId, channelId); + } + } + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Deleting message {MessageId} in channel {ChannelId} failed (may already be gone).")] + private static partial void LogDeleteFailed(ILogger logger, + Exception exception, + ulong messageId, + ulong channelId); } diff --git a/src/RustPlusBot.Features.Switches/Posting/ISwitchChannelPoster.cs b/src/RustPlusBot.Features.Switches/Posting/ISwitchChannelPoster.cs index e0f34f44..6aee58af 100644 --- a/src/RustPlusBot.Features.Switches/Posting/ISwitchChannelPoster.cs +++ b/src/RustPlusBot.Features.Switches/Posting/ISwitchChannelPoster.cs @@ -16,4 +16,11 @@ internal interface ISwitchChannelPoster global::Discord.Embed embed, global::Discord.MessageComponent components, CancellationToken cancellationToken); + + /// Deletes a message in the given channel (missing message/channel tolerated). + /// The #switches channel id. + /// The message id to delete. + /// A cancellation token. + /// A task that completes when the message has been deleted (or the failure swallowed). + Task DeleteMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken); } diff --git a/src/RustPlusBot.Features.Switches/Relaying/SwitchWipePurger.cs b/src/RustPlusBot.Features.Switches/Relaying/SwitchWipePurger.cs new file mode 100644 index 00000000..229b8de3 --- /dev/null +++ b/src/RustPlusBot.Features.Switches/Relaying/SwitchWipePurger.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Switches; +using RustPlusBot.Features.Switches.Posting; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Switches; + +namespace RustPlusBot.Features.Switches.Relaying; + +/// +/// Deletes all of a wiped server's switches: entity ids are permanently invalid after a wipe, so the rows +/// are removed first, then the #switches embeds best-effort. Idempotent: a duplicate wipe event finds no +/// rows and is a no-op. +/// +/// Opens scopes for the scoped switch store. +/// Resolves the #switches channel id. +/// Deletes the switch embeds. +/// The logger. +internal sealed partial class SwitchWipePurger( + IServiceScopeFactory scopeFactory, + ISwitchChannelLocator locator, + ISwitchChannelPoster poster, + ILogger logger) +{ + /// Handles one by purging the server's switches. + /// The wipe event. + /// A cancellation token. + /// A task that completes when rows and embeds have been removed. + public async Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(evt); + IReadOnlyList switches; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + switches = await store.ListByServerAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + foreach (var device in switches) + { + await store.RemoveAsync(evt.GuildId, evt.ServerId, device.EntityId, ct).ConfigureAwait(false); + } + } + + if (switches.Count == 0) + { + return; + } + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false); + if (channelId is { } cid) + { + foreach (var device in switches) + { + if (device.MessageId is { } messageId) + { + await poster.DeleteMessageAsync(cid, messageId, ct).ConfigureAwait(false); + } + } + } + + LogPurged(logger, switches.Count, evt.GuildId, evt.ServerId); + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "Purged {Count} switches after wipe for guild {GuildId} server {ServerId}.")] + private static partial void LogPurged(ILogger logger, int count, ulong guildId, Guid serverId); +} diff --git a/src/RustPlusBot.Features.Switches/SwitchServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Switches/SwitchServiceCollectionExtensions.cs index d35a0046..59d0810e 100644 --- a/src/RustPlusBot.Features.Switches/SwitchServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Switches/SwitchServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ public static IServiceCollection AddSwitches(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); // Contribute this assembly's interaction modules to the Discord layer. diff --git a/src/RustPlusBot.Features.Wipes/Announcing/IWipeAnnouncer.cs b/src/RustPlusBot.Features.Wipes/Announcing/IWipeAnnouncer.cs new file mode 100644 index 00000000..2bd4ca50 --- /dev/null +++ b/src/RustPlusBot.Features.Wipes/Announcing/IWipeAnnouncer.cs @@ -0,0 +1,13 @@ +using RustPlusBot.Abstractions.Events; + +namespace RustPlusBot.Features.Wipes.Announcing; + +/// Posts the wipe announcement embed to the wiped server's #events channel. +internal interface IWipeAnnouncer +{ + /// Handles one . + /// The wipe event. + /// A cancellation token. + /// A task that completes when the announcement has been posted (or skipped). + Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Wipes/Announcing/WipeAnnouncer.cs b/src/RustPlusBot.Features.Wipes/Announcing/WipeAnnouncer.cs new file mode 100644 index 00000000..6eb5742a --- /dev/null +++ b/src/RustPlusBot.Features.Wipes/Announcing/WipeAnnouncer.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Posting; +using RustPlusBot.Features.Wipes.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Wipes.Announcing; + +/// Default : resolves #events, the guild culture and the ping flag, then posts. +/// Opens scopes for the scoped workspace store. +/// Resolves the #events channel id. +/// Builds the announcement embed. +/// Sends the announcement message. +/// The logger. +internal sealed partial class WipeAnnouncer( + IServiceScopeFactory scopeFactory, + IEventChannelLocator locator, + WipeEmbedRenderer renderer, + IWipeChannelPoster poster, + ILogger logger) : IWipeAnnouncer +{ + /// + public async Task HandleServerWipedAsync(ServerWipedEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (channelId is not { } cid) + { + LogNoEventsChannel(logger, evt.GuildId, evt.ServerId); + return; + } + + string culture; + bool ping; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + culture = await workspace.GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + ping = await workspace.GetPingEveryoneOnWipeAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + } + + var embed = renderer.Render(evt, culture); + await poster.PostAsync(cid, ping ? "@everyone" : null, embed, cancellationToken).ConfigureAwait(false); + } + + [LoggerMessage(Level = LogLevel.Warning, + Message = "No #events channel for guild {GuildId} server {ServerId}; skipping wipe announcement.")] + private static partial void LogNoEventsChannel(ILogger logger, ulong guildId, Guid serverId); +} diff --git a/src/RustPlusBot.Features.Wipes/Detection/IWipeDetector.cs b/src/RustPlusBot.Features.Wipes/Detection/IWipeDetector.cs new file mode 100644 index 00000000..3ce3682a --- /dev/null +++ b/src/RustPlusBot.Features.Wipes/Detection/IWipeDetector.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Wipes.Detection; + +/// Checks a freshly connected server against its persisted wipe baseline. +internal interface IWipeDetector +{ + /// Reads live info/world, diffs against the baseline, and publishes on a wipe. + /// The owning guild snowflake. + /// The server that just connected. + /// A cancellation token. + /// A task that completes when the check (and any event publication) has finished. + Task CheckAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Wipes/Detection/WipeDetector.cs b/src/RustPlusBot.Features.Wipes/Detection/WipeDetector.cs new file mode 100644 index 00000000..7082a629 --- /dev/null +++ b/src/RustPlusBot.Features.Wipes/Detection/WipeDetector.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Persistence.Wipes; + +namespace RustPlusBot.Features.Wipes.Detection; + +/// +/// Default . A wipe always implies a server restart, so the check runs on each +/// transition to connected: the server wiped when the wipe time advanced beyond a small jitter tolerance +/// or the map seed/size changed. The first-ever observation backfills the baseline silently so existing +/// deployments never see a false wipe on upgrade. +/// +/// Opens scopes for the scoped baseline store. +/// Reads live server info from the socket. +/// Publishes . +/// The logger. +internal sealed partial class WipeDetector( + IServiceScopeFactory scopeFactory, + IRustServerQuery query, + IEventBus eventBus, + ILogger logger) : IWipeDetector +{ + /// Absorbs clock jitter in the reported wipe time across restarts; anything larger counts as a wipe. + private static readonly TimeSpan _wipeTimeTolerance = TimeSpan.FromMinutes(1); + + /// + public async Task CheckAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + var info = await query.GetServerInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + var world = await query.GetWorldAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (info is null || world is null) + { + return; // Socket dropped mid-check; the next reconnect retries. + } + + WipeBaseline? baseline; + WipeBaseline observed; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + baseline = await store.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (baseline is null) + { + return; // Server removed mid-check. + } + + observed = new WipeBaseline(info.WipeTimeUtc ?? baseline.WipeTimeUtc, world.Seed, world.WorldSize); + if (baseline == observed) + { + return; // Plain reconnect with nothing changed. + } + + await store.SetAsync(guildId, serverId, observed, cancellationToken).ConfigureAwait(false); + } + + if (baseline is { WipeTimeUtc: null, MapSeed: null, MapSize: null }) + { + LogBaselineStored(logger, guildId, serverId); + return; // First observation ever: backfill silently. + } + + var wiped = + (info.WipeTimeUtc is { } newWipe && baseline.WipeTimeUtc is { } oldWipe + && newWipe > oldWipe + _wipeTimeTolerance) + || (baseline.MapSeed is { } oldSeed && world.Seed != oldSeed) + || (baseline.MapSize is { } oldSize && world.WorldSize != oldSize); + if (!wiped) + { + return; // Jitter within tolerance or a null field backfilled — baseline refreshed silently. + } + + await eventBus.PublishAsync( + new ServerWipedEvent(guildId, serverId, baseline.WipeTimeUtc, info.WipeTimeUtc, world.Seed, + world.WorldSize), + cancellationToken) + .ConfigureAwait(false); + LogWipeDetected(logger, guildId, serverId, world.Seed, world.WorldSize); + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "Stored first wipe baseline for guild {GuildId} server {ServerId}.")] + private static partial void LogBaselineStored(ILogger logger, ulong guildId, Guid serverId); + + [LoggerMessage(Level = LogLevel.Information, + Message = "Wipe detected for guild {GuildId} server {ServerId}: seed {Seed}, size {WorldSize}.")] + private static partial void LogWipeDetected(ILogger logger, + ulong guildId, + Guid serverId, + uint seed, + uint worldSize); +} diff --git a/src/RustPlusBot.Features.Wipes/Hosting/WipesHostedService.cs b/src/RustPlusBot.Features.Wipes/Hosting/WipesHostedService.cs new file mode 100644 index 00000000..04d27e8e --- /dev/null +++ b/src/RustPlusBot.Features.Wipes/Hosting/WipesHostedService.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Announcing; +using RustPlusBot.Features.Wipes.Detection; + +namespace RustPlusBot.Features.Wipes.Hosting; + +/// Runs the wipe-check loop (on reconnect transitions) and the wipe-announcement loop. +/// The in-process event bus. +/// Diffs the live server against the persisted baseline. +/// Posts the wipe announcement in #events. +/// The logger. +internal sealed partial class WipesHostedService( + IEventBus eventBus, + IWipeDetector detector, + IWipeAnnouncer announcer, + ILogger logger) : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private Task? _statusLoop; + private Task? _wipedLoop; + + /// + public void Dispose() => _cts.Dispose(); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None); + _wipedLoop = Task.Run(() => ConsumeWipedAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync().ConfigureAwait(false); + foreach (var loop in new[] + { + _statusLoop, _wipedLoop + }.Where(t => t is not null)) + { + try + { +#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop. + await loop!.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + } + + private async Task ConsumeStatusAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + if (!evt.IsConnected || evt.WasConnected) + { + continue; + } + + await detector.CheckAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogStatusLoopFaulted(logger, ex); + } + } + + private async Task ConsumeWipedAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await announcer.HandleServerWipedAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogWipedLoopFaulted(logger, ex); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Wipe connection-status loop faulted.")] + private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Wipe announcement loop faulted.")] + private static partial void LogWipedLoopFaulted(ILogger logger, Exception exception); +} diff --git a/src/RustPlusBot.Features.Wipes/Posting/DiscordWipeChannelPoster.cs b/src/RustPlusBot.Features.Wipes/Posting/DiscordWipeChannelPoster.cs new file mode 100644 index 00000000..424ff324 --- /dev/null +++ b/src/RustPlusBot.Features.Wipes/Posting/DiscordWipeChannelPoster.cs @@ -0,0 +1,54 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; + +namespace RustPlusBot.Features.Wipes.Posting; + +/// Posts wipe announcements in #events via the gateway client. Untested integration shim. +/// The Discord socket client (raw send so the optional @everyone mention resolves). +/// The logger. +internal sealed partial class DiscordWipeChannelPoster( + DiscordSocketClient client, + ILogger logger) : IWipeChannelPoster +{ + /// + public async Task PostAsync(ulong channelId, string? content, Embed embed, CancellationToken cancellationToken) + { + try + { + var options = new RequestOptions + { + CancelToken = cancellationToken + }; + if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) + is not ITextChannel channel) + { + return; + } + + // Only permit mentions when we are deliberately pinging (content carries the @everyone). + // For an embed-only post, suppress all mentions so incidental mention-like text in the + // localized embed can never ping a user or role. + await channel.SendMessageAsync( + content, + embed: embed, + options: options, + allowedMentions: content is null ? AllowedMentions.None : AllowedMentions.All) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Shutdown — let the loop unwind. + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the wipe loop; swallow the failure. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPostFailed(logger, ex, channelId); + } + } + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Posting the wipe announcement in channel {ChannelId} failed.")] + private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId); +} diff --git a/src/RustPlusBot.Features.Wipes/Posting/IWipeChannelPoster.cs b/src/RustPlusBot.Features.Wipes/Posting/IWipeChannelPoster.cs new file mode 100644 index 00000000..bc61697f --- /dev/null +++ b/src/RustPlusBot.Features.Wipes/Posting/IWipeChannelPoster.cs @@ -0,0 +1,15 @@ +using Discord; + +namespace RustPlusBot.Features.Wipes.Posting; + +/// Posts the one-off wipe announcement to a Discord channel. +internal interface IWipeChannelPoster +{ + /// Posts (with optional mention ) to the channel. + /// The target Discord channel id. + /// Optional message content (e.g. "@everyone"), or null for embed-only. + /// The announcement embed. + /// A cancellation token. + /// A task that completes when the message has been sent (or the failure swallowed). + Task PostAsync(ulong channelId, string? content, Embed embed, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Wipes/Rendering/WipeEmbedRenderer.cs b/src/RustPlusBot.Features.Wipes/Rendering/WipeEmbedRenderer.cs new file mode 100644 index 00000000..9bfa9c82 --- /dev/null +++ b/src/RustPlusBot.Features.Wipes/Rendering/WipeEmbedRenderer.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using Discord; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Wipes.Rendering; + +/// Renders the server-wiped announcement embed posted in #events. +/// String resolution. +internal sealed class WipeEmbedRenderer(ILocalizer localizer) +{ + /// Renders the announcement for a guild culture. + /// The wipe event. + /// The guild culture ("en"/"fr"). + /// The built embed. + public Embed Render(ServerWipedEvent evt, string culture) + { + ArgumentNullException.ThrowIfNull(evt); + var builder = new EmbedBuilder() + .WithTitle(localizer.Get("wipe.title", culture)) + .WithDescription(localizer.Get("wipe.body", culture)) + .WithColor(Color.Orange); + + if (evt.NewWipeTimeUtc is { } wipedAt) + { + builder.AddField(localizer.Get("wipe.field.wipedat", culture), + $"", inline: true); + } + + builder + .AddField(localizer.Get("wipe.field.mapsize", culture), + evt.WorldSize.ToString(CultureInfo.InvariantCulture), inline: true) + .AddField(localizer.Get("wipe.field.seed", culture), + evt.Seed.ToString(CultureInfo.InvariantCulture), inline: true); + return builder.Build(); + } +} diff --git a/src/RustPlusBot.Features.Wipes/RustPlusBot.Features.Wipes.csproj b/src/RustPlusBot.Features.Wipes/RustPlusBot.Features.Wipes.csproj new file mode 100644 index 00000000..59229ded --- /dev/null +++ b/src/RustPlusBot.Features.Wipes/RustPlusBot.Features.Wipes.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs new file mode 100644 index 00000000..8acc736f --- /dev/null +++ b/src/RustPlusBot.Features.Wipes/WipeServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Wipes.Announcing; +using RustPlusBot.Features.Wipes.Detection; +using RustPlusBot.Features.Wipes.Hosting; +using RustPlusBot.Features.Wipes.Posting; +using RustPlusBot.Features.Wipes.Rendering; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Wipes; + +/// DI registration for the wipe-detection feature. +public static class WipeServiceCollectionExtensions +{ + /// Registers the localizer, detector, renderer, poster, announcer, and hosted service. + /// The service collection to add to. + /// The same service collection, for chaining. + public static IServiceCollection AddWipes(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddRustPlusBotLocalization(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + + return services; + } +} diff --git a/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs index c8a76680..eeabe29f 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs @@ -2,23 +2,31 @@ using RustPlusBot.Features.Workspace.Gateway; using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Workspace.Messages; -/// Renders the global #settings message with the language selector. +/// Renders the global #settings message with the language selector and the wipe-ping toggle. +/// Reads the guild's wipe-ping flag. /// String resolution. -internal sealed class SettingsMessageRenderer(ILocalizer localizer) : IMessageRenderer +internal sealed class SettingsMessageRenderer(IWorkspaceStore store, ILocalizer localizer) : IMessageRenderer { /// Custom id of the language select menu, handled by the settings component module. public const string LanguageSelectId = "workspace:settings:culture"; + /// Custom id of the @everyone-on-wipe toggle button, handled by the settings component module. + public const string WipePingButtonId = "workspace:settings:wipeping"; + /// public string MessageKey => WorkspaceMessageKeys.SettingsMain; /// - public ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); + var ping = await store.GetPingEveryoneOnWipeAsync(context.GuildId, cancellationToken).ConfigureAwait(false); + var embed = new EmbedBuilder() .WithTitle(localizer.Get("settings.title", context.Culture)) .WithDescription(localizer.Get("settings.body", context.Culture)) @@ -30,8 +38,15 @@ public ValueTask RenderAsync(MessageRenderContext context, Cance .WithPlaceholder(localizer.Get("settings.language.label", context.Culture)) .AddOption("English", "en") .AddOption("Français", "fr"); - var components = new ComponentBuilder().WithSelectMenu(menu).Build(); + var wipePing = new ButtonBuilder() + .WithCustomId(WipePingButtonId) + .WithLabel(localizer.Get(ping ? "settings.wipeping.on" : "settings.wipeping.off", context.Culture)) + .WithStyle(ping ? ButtonStyle.Success : ButtonStyle.Secondary); + var components = new ComponentBuilder() + .WithSelectMenu(menu) + .WithButton(wipePing, row: 1) + .Build(); - return ValueTask.FromResult(new MessagePayload(null, embed, components)); + return new MessagePayload(null, embed, components); } } diff --git a/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs b/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs index f5c9123e..a06e1add 100644 --- a/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs +++ b/src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs @@ -46,4 +46,35 @@ public async Task SetCultureAsync(string[] selectedValues) await scope.DisposeAsync().ConfigureAwait(false); } } + + /// Toggles the @everyone-on-wipe ping and re-renders the workspace. + [ComponentInteraction(SettingsMessageRenderer.WipePingButtonId)] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task ToggleWipePingAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + try + { + var store = scope.ServiceProvider.GetRequiredService(); + var enabled = !await store.GetPingEveryoneOnWipeAsync(Context.Guild.Id).ConfigureAwait(false); + await store.SetPingEveryoneOnWipeAsync(Context.Guild.Id, enabled).ConfigureAwait(false); + + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.ReconcileGlobalAsync(Context.Guild.Id).ConfigureAwait(false); + + await FollowupAsync($"@everyone ping on wipe {(enabled ? "enabled" : "disabled")}.", ephemeral: true) + .ConfigureAwait(false); + } + finally + { + await scope.DisposeAsync().ConfigureAwait(false); + } + } } diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index 5195edf9..d75b5e02 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -13,6 +13,7 @@ using RustPlusBot.Features.Players; using RustPlusBot.Features.StorageMonitors; using RustPlusBot.Features.Switches; +using RustPlusBot.Features.Wipes; using RustPlusBot.Features.Workspace; using RustPlusBot.Host.Credentials; using RustPlusBot.Persistence; @@ -87,6 +88,7 @@ builder.Services.AddSwitches(); builder.Services.AddAlarms(); builder.Services.AddStorageMonitors(); +builder.Services.AddWipes(); var host = builder.Build(); diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj index a3cb4235..1f7839bd 100644 --- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj +++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj @@ -31,6 +31,7 @@ + diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 071261a0..432e00e0 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -834,4 +834,25 @@ Disponibilité du bot : {0} + + 🧹 Serveur wipé + + + Le serveur a wipé. Tous les appareils connectés appairés ont été supprimés — appairez-les à nouveau en jeu pour continuer à utiliser vos interrupteurs, alarmes et moniteurs de stockage. + + + Wipe + + + Taille de la carte + + + Seed + + + 🔔 Ping @everyone au wipe : activé + + + 🔕 Ping @everyone au wipe : désactivé + \ No newline at end of file diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index b2dc63e7..51f3735f 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -834,4 +834,25 @@ Bot uptime: {0} + + 🧹 Server wiped + + + The server has wiped. All paired smart devices were removed — pair them again in game to keep using your switches, alarms and storage monitors. + + + Wiped + + + Map size + + + Seed + + + 🔔 Ping @everyone on wipe: On + + + 🔕 Ping @everyone on wipe: Off + \ No newline at end of file diff --git a/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs index cce8d51f..deb07d40 100644 --- a/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs +++ b/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs @@ -18,5 +18,7 @@ public void Configure(EntityTypeBuilder builder) s.GuildId, s.Ip, s.Port }).IsUnique(); builder.HasIndex(s => s.FacepunchServerId); + builder.Property(s => s.LastMapSeed).HasConversion(); + builder.Property(s => s.LastMapSize).HasConversion(); } } diff --git a/src/RustPlusBot.Persistence/Migrations/20260715195556_WipeDetection.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260715195556_WipeDetection.Designer.cs new file mode 100644 index 00000000..1b0fbad2 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260715195556_WipeDetection.Designer.cs @@ -0,0 +1,741 @@ +// +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("20260715195556_WipeDetection")] + partial class WipeDetection + { + /// + 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.Alarms.SmartAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTriggeredUtc") + .HasColumnType("TEXT"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("PingEveryone") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RelayToTeamChat") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartAlarms"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.HasKey("ServerId"); + + b.ToTable("ServerCommandSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("ActiveCredentialId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("RustServerId"); + + b.ToTable("ConnectionStates"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedFcmCredentials") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "OwnerUserId") + .IsUnique(); + + b.ToTable("FcmRegistrations"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedPlayerToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "OwnerUserId") + .IsUnique(); + + b.ToTable("PlayerCredentials"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("PairedEntities"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("EventSubscriptions"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PingEveryoneOnWipe") + .HasColumnType("INTEGER"); + + b.HasKey("GuildId"); + + b.ToTable("GuildSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GridStyle") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ShowGrid") + .HasColumnType("INTEGER"); + + b.Property("ShowMarkers") + .HasColumnType("INTEGER"); + + b.Property("ShowMonuments") + .HasColumnType("INTEGER"); + + b.Property("ShowPlayers") + .HasColumnType("INTEGER"); + + b.Property("ShowRigs") + .HasColumnType("INTEGER"); + + b.Property("ShowTunnels") + .HasColumnType("INTEGER"); + + b.Property("ShowVendor") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.ToTable("ServerMapSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .HasColumnType("INTEGER"); + + b.Property("FacepunchServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Ip") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastMapSeed") + .HasColumnType("INTEGER"); + + b.Property("LastMapSize") + .HasColumnType("INTEGER"); + + b.Property("LastWipeTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FacepunchServerId"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartStorageMonitors"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartSwitches"); + }); + + 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.Alarms.SmartAlarm", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260715195556_WipeDetection.cs b/src/RustPlusBot.Persistence/Migrations/20260715195556_WipeDetection.cs new file mode 100644 index 00000000..45038a55 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260715195556_WipeDetection.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class WipeDetection : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastMapSeed", + table: "RustServers", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastMapSize", + table: "RustServers", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastWipeTimeUtc", + table: "RustServers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PingEveryoneOnWipe", + table: "GuildSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastMapSeed", + table: "RustServers"); + + migrationBuilder.DropColumn( + name: "LastMapSize", + table: "RustServers"); + + migrationBuilder.DropColumn( + name: "LastWipeTimeUtc", + table: "RustServers"); + + migrationBuilder.DropColumn( + name: "PingEveryoneOnWipe", + table: "GuildSettings"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index e441cd7f..fba645bc 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -353,6 +353,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("TEXT"); + b.Property("PingEveryoneOnWipe") + .HasColumnType("INTEGER"); + b.HasKey("GuildId"); b.ToTable("GuildSettings"); @@ -415,6 +418,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(255) .HasColumnType("TEXT"); + b.Property("LastMapSeed") + .HasColumnType("INTEGER"); + + b.Property("LastMapSize") + .HasColumnType("INTEGER"); + + b.Property("LastWipeTimeUtc") + .HasColumnType("TEXT"); + b.Property("Name") .IsRequired() .HasMaxLength(128) diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs index 1f39ee14..63958d1f 100644 --- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using RustPlusBot.Persistence.Servers; using RustPlusBot.Persistence.StorageMonitors; using RustPlusBot.Persistence.Switches; +using RustPlusBot.Persistence.Wipes; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Persistence; @@ -45,6 +46,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/RustPlusBot.Persistence/Wipes/IWipeBaselineStore.cs b/src/RustPlusBot.Persistence/Wipes/IWipeBaselineStore.cs new file mode 100644 index 00000000..5ef44f73 --- /dev/null +++ b/src/RustPlusBot.Persistence/Wipes/IWipeBaselineStore.cs @@ -0,0 +1,20 @@ +namespace RustPlusBot.Persistence.Wipes; + +/// Reads/writes the per-server wipe baseline stored on the RustServer row. +public interface IWipeBaselineStore +{ + /// Gets the baseline, or null when the server row does not exist. + /// The owning guild snowflake. + /// The server id. + /// A cancellation token. + /// The baseline (all-null members before first observation), or null for an unknown server. + Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + + /// Overwrites the baseline (no-op when the server row does not exist). + /// The owning guild snowflake. + /// The server id. + /// The new baseline values. + /// A cancellation token. + /// A task that completes when the baseline has been persisted. + Task SetAsync(ulong guildId, Guid serverId, WipeBaseline baseline, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Persistence/Wipes/WipeBaseline.cs b/src/RustPlusBot.Persistence/Wipes/WipeBaseline.cs new file mode 100644 index 00000000..2339437a --- /dev/null +++ b/src/RustPlusBot.Persistence/Wipes/WipeBaseline.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Persistence.Wipes; + +/// The persisted wipe baseline for a server: the last observed wipe time and world identity. All-null before the first observation. +/// The last observed wipe time (UTC), or null when the server never reported one. +/// The last observed procedural map seed, or null before first observation. +/// The last observed world size (game units), or null before first observation. +public sealed record WipeBaseline(DateTimeOffset? WipeTimeUtc, uint? MapSeed, uint? MapSize); diff --git a/src/RustPlusBot.Persistence/Wipes/WipeBaselineStore.cs b/src/RustPlusBot.Persistence/Wipes/WipeBaselineStore.cs new file mode 100644 index 00000000..673cd0ec --- /dev/null +++ b/src/RustPlusBot.Persistence/Wipes/WipeBaselineStore.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; + +namespace RustPlusBot.Persistence.Wipes; + +/// Default over the RustServer baseline columns. +/// The bot database context. +public sealed class WipeBaselineStore(BotDbContext context) : IWipeBaselineStore +{ + /// + public async Task GetAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken = default) + { + var server = await context.RustServers + .SingleOrDefaultAsync(s => s.GuildId == guildId && s.Id == serverId, cancellationToken) + .ConfigureAwait(false); + return server is null + ? null + : new WipeBaseline(server.LastWipeTimeUtc, server.LastMapSeed, server.LastMapSize); + } + + /// + public async Task SetAsync(ulong guildId, + Guid serverId, + WipeBaseline baseline, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseline); + var server = await context.RustServers + .SingleOrDefaultAsync(s => s.GuildId == guildId && s.Id == serverId, cancellationToken) + .ConfigureAwait(false); + if (server is null) + { + return; + } + + server.LastWipeTimeUtc = baseline.WipeTimeUtc; + server.LastMapSeed = baseline.MapSeed; + server.LastMapSize = baseline.MapSize; + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs b/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs index 9d8ed85c..09ef5408 100644 --- a/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs +++ b/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs @@ -61,6 +61,19 @@ Task> GetChannelsAsync(ulong guildId, /// A cancellation token. Task SetCultureAsync(ulong guildId, string culture, CancellationToken cancellationToken = default); + /// True when the guild wants the server-wiped announcement to ping @everyone. Defaults to false. + /// The guild snowflake. + /// A cancellation token. + /// The current flag value. + Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationToken cancellationToken = default); + + /// Sets the @everyone-on-wipe flag (upserting the GuildSettings row). + /// The guild snowflake. + /// The new flag value. + /// A cancellation token. + /// A task that completes when the flag has been persisted. + Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, CancellationToken cancellationToken = default); + /// Deletes all provisioning rows (category + channels + messages) for one scope. /// The Discord guild snowflake. /// The Rust server id, or null for the global scope. diff --git a/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs index f48a039b..835e1cbb 100644 --- a/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs +++ b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs @@ -139,6 +139,39 @@ public async Task SetCultureAsync(ulong guildId, string culture, CancellationTok await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } + /// + public async Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationToken cancellationToken = default) + { + var settings = await context.GuildSettings + .SingleOrDefaultAsync(s => s.GuildId == guildId, cancellationToken) + .ConfigureAwait(false); + return settings?.PingEveryoneOnWipe ?? false; + } + + /// + public async Task SetPingEveryoneOnWipeAsync(ulong guildId, + bool enabled, + CancellationToken cancellationToken = default) + { + var settings = await context.GuildSettings + .SingleOrDefaultAsync(s => s.GuildId == guildId, cancellationToken) + .ConfigureAwait(false); + + if (settings is null) + { + context.GuildSettings.Add(new GuildSettings + { + GuildId = guildId, PingEveryoneOnWipe = enabled + }); + } + else + { + settings.PingEveryoneOnWipe = enabled; + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + /// public async Task DeleteScopeAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) { diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmWipePurgerTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmWipePurgerTests.cs new file mode 100644 index 00000000..5cedad81 --- /dev/null +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmWipePurgerTests.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Alarms; +using RustPlusBot.Features.Alarms.Posting; +using RustPlusBot.Features.Alarms.Relaying; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Alarms; + +namespace RustPlusBot.Features.Alarms.Tests; + +/// Unit tests for . +public sealed class AlarmWipePurgerTests +{ + private static readonly Guid ServerId = Guid.NewGuid(); + + private static Harness Create(IReadOnlyList alarms, ulong? channelId = 777UL) + { + var store = Substitute.For(); + store.ListByServerAsync(10UL, ServerId, Arg.Any()).Returns(alarms); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(10UL, ServerId, Arg.Any()).Returns(channelId); + var poster = Substitute.For(); + + var purger = new AlarmWipePurger(scopeFactory, locator, poster, + NullLogger.Instance); + return new Harness(purger, store, locator, poster); + } + + private static SmartAlarm Alarm(ulong entityId, ulong? messageId) => new() + { + GuildId = 10UL, + ServerId = ServerId, + EntityId = entityId, + Name = $"Alarm {entityId}", + MessageId = messageId + }; + + private static ServerWipedEvent Event() => new(10UL, ServerId, null, null, 1u, 3500u); + + [Fact] + public async Task Removes_rows_and_deletes_messages() + { + var h = Create([Alarm(1UL, 100UL), Alarm(2UL, 200UL)]); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 2UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 100UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 200UL, Arg.Any()); + } + + [Fact] + public async Task Alarm_without_message_is_removed_without_delete_call() + { + var h = Create([Alarm(1UL, messageId: null)]); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task Missing_channel_still_removes_rows() + { + var h = Create([Alarm(1UL, 100UL)], channelId: null); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task No_alarms_is_a_noop() + { + var h = Create([]); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().RemoveAsync(default, Guid.Empty, default, default); + await h.Locator.DidNotReceiveWithAnyArgs().GetChannelIdAsync(default, Guid.Empty, default); + } + + private sealed record Harness( + AlarmWipePurger Purger, + IAlarmStore Store, + IAlarmChannelLocator Locator, + IAlarmChannelPoster Poster); +} diff --git a/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs index 360f22d9..d893fe6b 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs @@ -65,11 +65,15 @@ private static Harness Create() Arg.Any(), Arg.Any()).Returns((ulong?)901UL); var coordinator = new AlarmPairingCoordinator(scopeFactory, pairingLocator, pairingPoster, alarmRenderer); + var purger = new AlarmWipePurger(scopeFactory, relayLocator, relayPoster, + NullLogger.Instance); + var bus = new InMemoryEventBus(); var service = new AlarmsHostedService( bus, coordinator, relay, + purger, NullLogger.Instance); return new Harness(service, bus, store, refresher, relayPoster, pairingLocator, pairingPoster); diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs index 281aa6b0..d33fab26 100644 --- a/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs @@ -55,11 +55,15 @@ private static Harness Create() Arg.Any(), Arg.Any()).Returns((ulong?)901UL); var coordinator = new StorageMonitorPairingCoordinator(scopeFactory, pairingLocator, pairingPoster, renderer); + var purger = new StorageMonitorWipePurger(scopeFactory, relayLocator, relayPoster, + NullLogger.Instance); + var bus = new InMemoryEventBus(); var service = new StorageMonitorsHostedService( bus, coordinator, relay, + purger, NullLogger.Instance); return new Harness(service, bus, store, relayPoster, relayLocator, pairingLocator, pairingPoster); diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorWipePurgerTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorWipePurgerTests.cs new file mode 100644 index 00000000..145db5a5 --- /dev/null +++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorWipePurgerTests.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.StorageMonitors; +using RustPlusBot.Features.StorageMonitors.Posting; +using RustPlusBot.Features.StorageMonitors.Relaying; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.StorageMonitors; + +namespace RustPlusBot.Features.StorageMonitors.Tests; + +/// Unit tests for . +public sealed class StorageMonitorWipePurgerTests +{ + private static readonly Guid ServerId = Guid.NewGuid(); + + private static Harness Create(IReadOnlyList monitors, ulong? channelId = 777UL) + { + var store = Substitute.For(); + store.ListByServerAsync(10UL, ServerId, Arg.Any()).Returns(monitors); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(10UL, ServerId, Arg.Any()).Returns(channelId); + var poster = Substitute.For(); + + var purger = new StorageMonitorWipePurger(scopeFactory, locator, poster, + NullLogger.Instance); + return new Harness(purger, store, locator, poster); + } + + private static SmartStorageMonitor Monitor(ulong entityId, ulong? messageId) => new() + { + GuildId = 10UL, + ServerId = ServerId, + EntityId = entityId, + Name = $"Monitor {entityId}", + MessageId = messageId + }; + + private static ServerWipedEvent Event() => new(10UL, ServerId, null, null, 1u, 3500u); + + [Fact] + public async Task Removes_rows_and_deletes_messages() + { + var h = Create([Monitor(1UL, 100UL), Monitor(2UL, 200UL)]); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 2UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 100UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 200UL, Arg.Any()); + } + + [Fact] + public async Task Monitor_without_message_is_removed_without_delete_call() + { + var h = Create([Monitor(1UL, messageId: null)]); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task Missing_channel_still_removes_rows() + { + var h = Create([Monitor(1UL, 100UL)], channelId: null); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task No_monitors_is_a_noop() + { + var h = Create([]); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().RemoveAsync(default, Guid.Empty, default, default); + await h.Locator.DidNotReceiveWithAnyArgs().GetChannelIdAsync(default, Guid.Empty, default); + } + + private sealed record Harness( + StorageMonitorWipePurger Purger, + IStorageMonitorStore Store, + IStorageMonitorChannelLocator Locator, + IStorageMonitorChannelPoster Poster); +} diff --git a/tests/RustPlusBot.Features.Switches.Tests/Hosting/SwitchesHostedServiceTests.cs b/tests/RustPlusBot.Features.Switches.Tests/Hosting/SwitchesHostedServiceTests.cs index 3215d8df..f7a34fca 100644 --- a/tests/RustPlusBot.Features.Switches.Tests/Hosting/SwitchesHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Switches.Tests/Hosting/SwitchesHostedServiceTests.cs @@ -49,11 +49,15 @@ private static Harness Create() Arg.Any(), Arg.Any()).Returns((ulong?)901UL); var coordinator = new SwitchPairingCoordinator(scopeFactory, pairingLocator, pairingPoster, renderer); + var purger = new SwitchWipePurger(scopeFactory, relayLocator, relayPoster, + NullLogger.Instance); + var bus = new InMemoryEventBus(); var service = new SwitchesHostedService( bus, coordinator, relay, + purger, NullLogger.Instance); return new Harness(service, bus, store, relayPoster, relayLocator, pairingLocator, pairingPoster); diff --git a/tests/RustPlusBot.Features.Switches.Tests/SwitchWipePurgerTests.cs b/tests/RustPlusBot.Features.Switches.Tests/SwitchWipePurgerTests.cs new file mode 100644 index 00000000..8703e12d --- /dev/null +++ b/tests/RustPlusBot.Features.Switches.Tests/SwitchWipePurgerTests.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Switches; +using RustPlusBot.Features.Switches.Posting; +using RustPlusBot.Features.Switches.Relaying; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Switches; + +namespace RustPlusBot.Features.Switches.Tests; + +/// Unit tests for . +public sealed class SwitchWipePurgerTests +{ + private static readonly Guid ServerId = Guid.NewGuid(); + + private static Harness Create(IReadOnlyList switches, ulong? channelId = 777UL) + { + var store = Substitute.For(); + store.ListByServerAsync(10UL, ServerId, Arg.Any()).Returns(switches); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(10UL, ServerId, Arg.Any()).Returns(channelId); + var poster = Substitute.For(); + + var purger = new SwitchWipePurger(scopeFactory, locator, poster, + NullLogger.Instance); + return new Harness(purger, store, locator, poster); + } + + private static SmartSwitch Switch(ulong entityId, ulong? messageId) => new() + { + GuildId = 10UL, + ServerId = ServerId, + EntityId = entityId, + Name = $"Switch {entityId}", + MessageId = messageId + }; + + private static ServerWipedEvent Event() => new(10UL, ServerId, null, null, 1u, 3500u); + + [Fact] + public async Task Removes_rows_and_deletes_messages() + { + var h = Create([Switch(1UL, 100UL), Switch(2UL, 200UL)]); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 2UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 100UL, Arg.Any()); + await h.Poster.Received(1).DeleteMessageAsync(777UL, 200UL, Arg.Any()); + } + + [Fact] + public async Task Switch_without_message_is_removed_without_delete_call() + { + var h = Create([Switch(1UL, messageId: null)]); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task Missing_channel_still_removes_rows() + { + var h = Create([Switch(1UL, 100UL)], channelId: null); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.Received(1).RemoveAsync(10UL, ServerId, 1UL, Arg.Any()); + await h.Poster.DidNotReceiveWithAnyArgs().DeleteMessageAsync(default, default, default); + } + + [Fact] + public async Task No_switches_is_a_noop() + { + var h = Create([]); + + await h.Purger.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().RemoveAsync(default, Guid.Empty, default, default); + await h.Locator.DidNotReceiveWithAnyArgs().GetChannelIdAsync(default, Guid.Empty, default); + } + + private sealed record Harness( + SwitchWipePurger Purger, + ISwitchStore Store, + ISwitchChannelLocator Locator, + ISwitchChannelPoster Poster); +} diff --git a/tests/RustPlusBot.Features.Wipes.Tests/Hosting/WipesHostedServiceTests.cs b/tests/RustPlusBot.Features.Wipes.Tests/Hosting/WipesHostedServiceTests.cs new file mode 100644 index 00000000..f1e06585 --- /dev/null +++ b/tests/RustPlusBot.Features.Wipes.Tests/Hosting/WipesHostedServiceTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Announcing; +using RustPlusBot.Features.Wipes.Detection; +using RustPlusBot.Features.Wipes.Hosting; + +namespace RustPlusBot.Features.Wipes.Tests.Hosting; + +/// Verifies routes bus events to the detector and announcer. +public sealed class WipesHostedServiceTests +{ + private static Harness Create() + { + var detector = Substitute.For(); + var announcer = Substitute.For(); + var bus = new InMemoryEventBus(); + var service = new WipesHostedService(bus, detector, announcer, + NullLogger.Instance); + return new Harness(service, bus, detector, announcer); + } + + private static async Task PublishUntilReceivedAsync( + InMemoryEventBus bus, + TEvent evt, + object substitute, + string methodName) + where TEvent : notnull + { + // Same poll-until-deadline shape as AlarmsHostedServiceTests: the consumer loops attach + // asynchronously after StartAsync, so re-publish on each tick until the substitute records + // the call instead of asserting immediately after a single publish. + var deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline + && !substitute.ReceivedCalls().Any(c => c.GetMethodInfo().Name == methodName)) + { + await bus.PublishAsync(evt); + await Task.Delay(20); + } + } + + [Fact] + public async Task Connected_transition_runs_the_detector() + { + var h = Create(); + await h.Service.StartAsync(default); + var serverId = Guid.NewGuid(); + var evt = new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: true, WasConnected: false); + + await PublishUntilReceivedAsync(h.Bus, evt, h.Detector, nameof(IWipeDetector.CheckAsync)); + + await h.Detector.Received().CheckAsync(10UL, serverId, Arg.Any()); + await h.Service.StopAsync(default); + } + + [Fact] + public async Task Disconnect_does_not_run_the_detector() + { + var h = Create(); + await h.Service.StartAsync(default); + var evt = new ConnectionStatusChangedEvent(10UL, Guid.NewGuid(), IsConnected: false, WasConnected: true); + + // Re-publish for a window (same eager-subscription concern as PublishUntilReceivedAsync) so the + // subscriber loop has definitely attached and processed at least one delivery before asserting + // the negative outcome below. + for (var i = 0; i < 10; i++) + { + await h.Bus.PublishAsync(evt); + await Task.Delay(20); + } + + await h.Detector.DidNotReceiveWithAnyArgs().CheckAsync(default, Guid.Empty, default); + await h.Service.StopAsync(default); + } + + [Fact] + public async Task Steady_state_republish_does_not_run_the_detector() + { + var h = Create(); + await h.Service.StartAsync(default); + var evt = new ConnectionStatusChangedEvent(10UL, Guid.NewGuid(), IsConnected: true, WasConnected: true); + + // Re-publish for a window (same eager-subscription concern as PublishUntilReceivedAsync) so the + // subscriber loop has definitely attached and processed at least one delivery before asserting + // the negative outcome below. + for (var i = 0; i < 10; i++) + { + await h.Bus.PublishAsync(evt); + await Task.Delay(20); + } + + await h.Detector.DidNotReceiveWithAnyArgs().CheckAsync(default, Guid.Empty, default); + await h.Service.StopAsync(default); + } + + [Fact] + public async Task ServerWipedEvent_routes_to_the_announcer() + { + var h = Create(); + await h.Service.StartAsync(default); + var evt = new ServerWipedEvent(10UL, Guid.NewGuid(), null, null, 1u, 3500u); + + await PublishUntilReceivedAsync(h.Bus, evt, h.Announcer, nameof(IWipeAnnouncer.HandleServerWipedAsync)); + + await h.Announcer.Received().HandleServerWipedAsync(evt, Arg.Any()); + await h.Service.StopAsync(default); + } + + private sealed record Harness( + WipesHostedService Service, + InMemoryEventBus Bus, + IWipeDetector Detector, + IWipeAnnouncer Announcer); +} diff --git a/tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj b/tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj new file mode 100644 index 00000000..45a5087a --- /dev/null +++ b/tests/RustPlusBot.Features.Wipes.Tests/RustPlusBot.Features.Wipes.Tests.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/RustPlusBot.Features.Wipes.Tests/WipeAnnouncerTests.cs b/tests/RustPlusBot.Features.Wipes.Tests/WipeAnnouncerTests.cs new file mode 100644 index 00000000..0394e4c9 --- /dev/null +++ b/tests/RustPlusBot.Features.Wipes.Tests/WipeAnnouncerTests.cs @@ -0,0 +1,101 @@ +using Discord; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Announcing; +using RustPlusBot.Features.Wipes.Posting; +using RustPlusBot.Features.Wipes.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Wipes.Tests; + +/// Unit tests for . +public sealed class WipeAnnouncerTests +{ + private static readonly Guid ServerId = Guid.NewGuid(); + + private static Harness Create(ulong? channelId = 777UL, bool ping = false, string culture = "en") + { + var workspace = Substitute.For(); + workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns(culture); + workspace.GetPingEveryoneOnWipeAsync(Arg.Any(), Arg.Any()).Returns(ping); + + var services = new ServiceCollection(); + services.AddScoped(_ => workspace); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(channelId); + var poster = Substitute.For(); + + var announcer = new WipeAnnouncer( + scopeFactory, + locator, + new WipeEmbedRenderer(new ResxLocalizer()), + poster, + NullLogger.Instance); + return new Harness(announcer, locator, poster); + } + + private static ServerWipedEvent Event() => + new(10UL, ServerId, null, new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), 999u, 4250u); + + [Fact] + public async Task Missing_events_channel_skips_posting() + { + var h = Create(channelId: null); + + await h.Announcer.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Poster.DidNotReceiveWithAnyArgs().PostAsync(0UL, default, null!, default); + } + + [Fact] + public async Task Posts_embed_without_ping_by_default() + { + var h = Create(); + + await h.Announcer.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Poster.Received(1).PostAsync( + 777UL, + null, + Arg.Is(e => e.Title == "🧹 Server wiped"), + Arg.Any()); + } + + [Fact] + public async Task Posts_everyone_content_when_guild_setting_enabled() + { + var h = Create(ping: true); + + await h.Announcer.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Poster.Received(1).PostAsync( + 777UL, + "@everyone", + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Renders_with_guild_culture() + { + var h = Create(culture: "fr"); + + await h.Announcer.HandleServerWipedAsync(Event(), CancellationToken.None); + + await h.Poster.Received(1).PostAsync( + 777UL, + null, + Arg.Is(e => e.Title == "🧹 Serveur wipé"), + Arg.Any()); + } + + private sealed record Harness(WipeAnnouncer Announcer, IEventChannelLocator Locator, IWipeChannelPoster Poster); +} diff --git a/tests/RustPlusBot.Features.Wipes.Tests/WipeDetectorTests.cs b/tests/RustPlusBot.Features.Wipes.Tests/WipeDetectorTests.cs new file mode 100644 index 00000000..9b9aa608 --- /dev/null +++ b/tests/RustPlusBot.Features.Wipes.Tests/WipeDetectorTests.cs @@ -0,0 +1,214 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Detection; +using RustPlusBot.Persistence.Wipes; + +namespace RustPlusBot.Features.Wipes.Tests; + +/// Unit tests for 's diff rules. +public sealed class WipeDetectorTests +{ + private static readonly DateTimeOffset OldWipe = new(2026, 6, 4, 18, 0, 0, TimeSpan.Zero); + private static readonly Guid ServerId = Guid.NewGuid(); + + private static Harness Create( + ServerInfoSnapshot? info, + WorldSnapshot? world, + WipeBaseline? baseline) + { + var query = Substitute.For(); + query.GetServerInfoAsync(10UL, ServerId, Arg.Any()).Returns(info); + query.GetWorldAsync(10UL, ServerId, Arg.Any()).Returns(world); + + var store = Substitute.For(); + store.GetAsync(10UL, ServerId, Arg.Any()).Returns(baseline); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var bus = Substitute.For(); + var detector = new WipeDetector(scopeFactory, query, bus, NullLogger.Instance); + return new Harness(detector, query, store, bus); + } + + private static ServerInfoSnapshot Info(DateTimeOffset? wipeTime) => new(5, 100, 0, wipeTime); + + [Fact] + public async Task Null_info_snapshot_aborts_silently() + { + var h = Create(info: null, world: new WorldSnapshot(3500u, 42u), + baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().SetAsync(default, Guid.Empty, null!, default); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Null_world_snapshot_aborts_silently() + { + var h = Create(info: Info(OldWipe), world: null, baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Missing_server_row_aborts_silently() + { + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), baseline: null); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().SetAsync(default, Guid.Empty, null!, default); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Empty_baseline_backfills_without_event() + { + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), + baseline: new WipeBaseline(null, null, null)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(OldWipe, 42u, 3500u), + Arg.Any()); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Unchanged_baseline_is_a_noop() + { + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), + baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().SetAsync(default, Guid.Empty, null!, default); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Wipe_time_advance_beyond_tolerance_publishes_event() + { + var newWipe = OldWipe.AddDays(7); + var h = Create(info: Info(newWipe), world: new WorldSnapshot(3500u, 42u), + baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(newWipe, 42u, 3500u), + Arg.Any()); + await h.Bus.Received(1).PublishAsync( + new ServerWipedEvent(10UL, ServerId, OldWipe, newWipe, 42u, 3500u), + Arg.Any()); + } + + [Fact] + public async Task Wipe_time_jitter_within_tolerance_refreshes_baseline_without_event() + { + var jittered = OldWipe.AddSeconds(30); + var h = Create(info: Info(jittered), world: new WorldSnapshot(3500u, 42u), + baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(jittered, 42u, 3500u), + Arg.Any()); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Seed_change_publishes_event() + { + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 999u), + baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Bus.Received(1).PublishAsync( + new ServerWipedEvent(10UL, ServerId, OldWipe, OldWipe, 999u, 3500u), + Arg.Any()); + } + + [Fact] + public async Task Size_change_publishes_event() + { + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(4250u, 42u), + baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Bus.Received(1).PublishAsync( + new ServerWipedEvent(10UL, ServerId, OldWipe, OldWipe, 42u, 4250u), + Arg.Any()); + } + + [Fact] + public async Task Null_observed_wipe_time_preserves_baseline_wipe_time() + { + var h = Create(info: Info(null), world: new WorldSnapshot(3500u, 42u), + baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.DidNotReceiveWithAnyArgs().SetAsync(default, Guid.Empty, null!, default); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Wipe_time_advance_at_exact_tolerance_is_not_a_wipe() + { + var atTolerance = OldWipe.AddSeconds(60); + var h = Create(info: Info(atTolerance), world: new WorldSnapshot(3500u, 42u), + baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(atTolerance, 42u, 3500u), + Arg.Any()); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + [Fact] + public async Task Wipe_time_advance_just_beyond_tolerance_publishes() + { + var beyondTolerance = OldWipe.AddSeconds(61); + var h = Create(info: Info(beyondTolerance), world: new WorldSnapshot(3500u, 42u), + baseline: new WipeBaseline(OldWipe, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Bus.Received(1).PublishAsync( + new ServerWipedEvent(10UL, ServerId, OldWipe, beyondTolerance, 42u, 3500u), + Arg.Any()); + } + + [Fact] + public async Task Wipe_time_appearing_from_null_backfills_without_event() + { + // Baseline had seed/size but no wipe time (server started reporting it): not a wipe. + var h = Create(info: Info(OldWipe), world: new WorldSnapshot(3500u, 42u), + baseline: new WipeBaseline(null, 42u, 3500u)); + + await h.Detector.CheckAsync(10UL, ServerId, CancellationToken.None); + + await h.Store.Received(1).SetAsync(10UL, ServerId, new WipeBaseline(OldWipe, 42u, 3500u), + Arg.Any()); + await h.Bus.DidNotReceiveWithAnyArgs().PublishAsync(null!, default); + } + + private sealed record Harness( + WipeDetector Detector, + IRustServerQuery Query, + IWipeBaselineStore Store, + IEventBus Bus); +} diff --git a/tests/RustPlusBot.Features.Wipes.Tests/WipeEmbedRendererTests.cs b/tests/RustPlusBot.Features.Wipes.Tests/WipeEmbedRendererTests.cs new file mode 100644 index 00000000..6760d50b --- /dev/null +++ b/tests/RustPlusBot.Features.Wipes.Tests/WipeEmbedRendererTests.cs @@ -0,0 +1,52 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Rendering; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Wipes.Tests; + +/// Unit tests for . +public sealed class WipeEmbedRendererTests +{ + private static readonly DateTimeOffset WipedAt = new(2026, 7, 2, 18, 0, 0, TimeSpan.Zero); + + private static ServerWipedEvent Event(DateTimeOffset? newWipe = null) => + new(10UL, Guid.NewGuid(), WipedAt.AddDays(-7), newWipe ?? WipedAt, 999u, 4250u); + + [Fact] + public void Render_includes_title_body_and_fields() + { + var renderer = new WipeEmbedRenderer(new ResxLocalizer()); + + var embed = renderer.Render(Event(), "en"); + + Assert.Equal("🧹 Server wiped", embed.Title); + Assert.False(string.IsNullOrEmpty(embed.Description)); + Assert.Contains(embed.Fields, f => f.Value == $""); + Assert.Contains(embed.Fields, f => f.Value == "4250"); + Assert.Contains(embed.Fields, f => f.Value == "999"); + } + + [Fact] + public void Render_omits_wiped_at_field_when_wipe_time_unknown() + { + var renderer = new WipeEmbedRenderer(new ResxLocalizer()); + + var embed = renderer.Render(Event() with + { + NewWipeTimeUtc = null + }, "en"); + + Assert.Equal(2, embed.Fields.Length); + } + + [Fact] + public void Render_localizes_to_french() + { + var renderer = new WipeEmbedRenderer(new ResxLocalizer()); + + var en = renderer.Render(Event(), "en"); + var fr = renderer.Render(Event(), "fr"); + + Assert.NotEqual(en.Description, fr.Description); + } +} diff --git a/tests/RustPlusBot.Features.Wipes.Tests/WipeRegistrationTests.cs b/tests/RustPlusBot.Features.Wipes.Tests/WipeRegistrationTests.cs new file mode 100644 index 00000000..74a7b5b5 --- /dev/null +++ b/tests/RustPlusBot.Features.Wipes.Tests/WipeRegistrationTests.cs @@ -0,0 +1,60 @@ +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Wipes.Announcing; +using RustPlusBot.Features.Wipes.Detection; +using RustPlusBot.Features.Wipes.Posting; +using RustPlusBot.Features.Wipes.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Wipes; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Wipes.Tests; + +/// Validates that registers all required services. +public sealed class WipeRegistrationTests +{ + /// Verifies core service descriptors are present after calling . + [Fact] + public void AddWipes_registers_core_services() + { + var services = new ServiceCollection(); + services.AddWipes(); + + Assert.Contains(services, d => d.ServiceType == typeof(ILocalizer)); + Assert.Contains(services, d => d.ServiceType == typeof(IWipeDetector)); + Assert.Contains(services, d => d.ServiceType == typeof(WipeEmbedRenderer)); + Assert.Contains(services, d => d.ServiceType == typeof(IWipeChannelPoster)); + Assert.Contains(services, d => d.ServiceType == typeof(IWipeAnnouncer)); + Assert.Contains(services, d => d.ServiceType == typeof(IHostedService)); + } + + /// Verifies the container resolves key types without captive-dependency errors when all cross-layer deps are provided. + [Fact] + public void AddWipes_resolves_without_captive_dependency_errors() + { + var services = new ServiceCollection(); + + services.AddLogging(); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(new DiscordSocketClient(new DiscordSocketConfig())); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + + services.AddWipes(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs index c34eb103..66aeb62f 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs @@ -17,6 +17,8 @@ internal sealed class FakeWorkspaceStore : IWorkspaceStore /// Key: scope|messageKey. private readonly ConcurrentDictionary _messages = new(); + private readonly ConcurrentDictionary _pingEveryoneOnWipe = new(); + public Task GetCategoryAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) => @@ -78,6 +80,15 @@ public Task SetCultureAsync(ulong guildId, string culture, CancellationToken can return Task.CompletedTask; } + public Task GetPingEveryoneOnWipeAsync(ulong guildId, CancellationToken cancellationToken = default) => + Task.FromResult(_pingEveryoneOnWipe.GetValueOrDefault(guildId, false)); + + public Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, CancellationToken cancellationToken = default) + { + _pingEveryoneOnWipe[guildId] = enabled; + return Task.CompletedTask; + } + public Task DeleteScopeAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) { _categories.TryRemove(Scope(guildId, serverId), out _); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index e3f0de7a..55518ea8 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -9,6 +9,7 @@ using RustPlusBot.Localization; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.Workspace; using DomainConnectionState = RustPlusBot.Domain.Connections.ConnectionState; namespace RustPlusBot.Features.Workspace.Tests.Messages; @@ -49,7 +50,9 @@ public async Task Information_ShowsServerCount() [Fact] public async Task Settings_HasLanguageSelectMenu() { - var renderer = new SettingsMessageRenderer(Loc); + var settingsStore = Substitute.For(); + settingsStore.GetPingEveryoneOnWipeAsync(Arg.Any(), Arg.Any()).Returns(false); + var renderer = new SettingsMessageRenderer(settingsStore, Loc); var payload = await renderer.RenderAsync(Global, default); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/SettingsMessageRendererWipePingTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/SettingsMessageRendererWipePingTests.cs new file mode 100644 index 00000000..767facaf --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/SettingsMessageRendererWipePingTests.cs @@ -0,0 +1,67 @@ +using Discord; +using NSubstitute; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Messages; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Tests.Messages; + +/// Wipe-ping toggle tests for . +public sealed class SettingsMessageRendererWipePingTests +{ + private static SettingsMessageRenderer Create(bool ping) + { + var store = Substitute.For(); + store.GetPingEveryoneOnWipeAsync(Arg.Any(), Arg.Any()).Returns(ping); + return new SettingsMessageRenderer(store, new ResxLocalizer()); + } + + private static ButtonComponent SingleButton(MessagePayload payload) => + payload.Components!.Components + .OfType() + .SelectMany(row => row.Components) + .OfType() + .Single(); + + [Fact] + public async Task Renders_off_button_by_default() + { + var renderer = Create(ping: false); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1UL, null, "en"), CancellationToken.None); + + var button = SingleButton(payload); + Assert.Equal(SettingsMessageRenderer.WipePingButtonId, button.CustomId); + Assert.Equal(ButtonStyle.Secondary, button.Style); + Assert.Equal("🔕 Ping @everyone on wipe: Off", button.Label); + } + + [Fact] + public async Task Renders_on_button_when_enabled() + { + var renderer = Create(ping: true); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1UL, null, "en"), CancellationToken.None); + + var button = SingleButton(payload); + Assert.Equal(ButtonStyle.Success, button.Style); + Assert.Equal("🔔 Ping @everyone on wipe: On", button.Label); + } + + [Fact] + public async Task Language_select_menu_is_still_present() + { + var renderer = Create(ping: false); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1UL, null, "en"), CancellationToken.None); + + var menu = payload.Components!.Components + .OfType() + .SelectMany(row => row.Components) + .OfType() + .Single(); + Assert.Equal(SettingsMessageRenderer.LanguageSelectId, menu.CustomId); + } +} diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index 477144ed..3e72e6b0 100644 --- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -41,6 +41,6 @@ public void English_covers_every_french_key() [Fact] public void Catalog_has_expected_key_count() { - Assert.Equal(274, EnglishKeys().Count); + Assert.Equal(281, EnglishKeys().Count); } } diff --git a/tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs b/tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs new file mode 100644 index 00000000..4e4f9b0a --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Servers/RustServerWipeColumnsTests.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Domain.Guilds; +using RustPlusBot.Domain.Servers; + +namespace RustPlusBot.Persistence.Tests.Servers; + +/// Round-trips the wipe-baseline columns and the wipe-ping guild flag through the migrated schema. +public sealed class RustServerWipeColumnsTests +{ + [Fact] + public async Task Wipe_baseline_columns_round_trip() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + + var server = new RustServer + { + GuildId = 10UL, + Name = "S", + Ip = "1.1.1.1", + Port = 28015, + LastWipeTimeUtc = new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), + LastMapSeed = 123456u, + LastMapSize = 4250u, + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var loaded = await context.RustServers.FindAsync(server.Id); + + Assert.NotNull(loaded); + Assert.Equal(new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), loaded.LastWipeTimeUtc); + Assert.Equal(123456u, loaded.LastMapSeed); + Assert.Equal(4250u, loaded.LastMapSize); + } + + [Fact] + public async Task New_server_has_empty_baseline_and_guild_ping_defaults_false() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + context.GuildSettings.Add(new GuildSettings + { + GuildId = 10UL + }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var loadedServer = await context.RustServers.FindAsync(server.Id); + var loadedGuild = await context.GuildSettings.SingleOrDefaultAsync(g => g.GuildId == 10UL); + + Assert.NotNull(loadedServer); + Assert.Null(loadedServer.LastWipeTimeUtc); + Assert.Null(loadedServer.LastMapSeed); + Assert.Null(loadedServer.LastMapSize); + Assert.NotNull(loadedGuild); + Assert.False(loadedGuild.PingEveryoneOnWipe); + } +} diff --git a/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs new file mode 100644 index 00000000..3d7c787c --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs @@ -0,0 +1,92 @@ +using Microsoft.Data.Sqlite; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Persistence.Wipes; + +namespace RustPlusBot.Persistence.Tests.Wipes; + +/// Unit tests for . +public sealed class WipeBaselineStoreTests +{ + private static (WipeBaselineStore Store, BotDbContext Context, SqliteConnection Conn) Create() + { + var (context, connection) = SqliteContextFixture.Create(); + return (new WipeBaselineStore(context), context, connection); + } + + private static async Task SeedServerAsync(BotDbContext context) + { + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + return server.Id; + } + + [Fact] + public async Task Get_returns_null_for_unknown_server() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + + var baseline = await store.GetAsync(10UL, Guid.NewGuid()); + + Assert.Null(baseline); + } + + [Fact] + public async Task Get_returns_empty_baseline_for_new_server() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + var baseline = await store.GetAsync(10UL, serverId); + + Assert.Equal(new WipeBaseline(null, null, null), baseline); + } + + [Fact] + public async Task Set_then_Get_round_trips() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + var baseline = new WipeBaseline(new DateTimeOffset(2026, 7, 2, 18, 0, 0, TimeSpan.Zero), 42u, 3500u); + + await store.SetAsync(10UL, serverId, baseline); + + Assert.Equal(baseline, await store.GetAsync(10UL, serverId)); + } + + [Fact] + public async Task Set_is_noop_for_unknown_server() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var unknownId = Guid.NewGuid(); + + await store.SetAsync(10UL, unknownId, new WipeBaseline(null, 42u, null)); + + // No throw; nothing persisted. + Assert.Null(await store.GetAsync(10UL, unknownId)); + } + + [Fact] + public async Task Get_is_guild_scoped() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + var baseline = await store.GetAsync(999UL, serverId); + + Assert.Null(baseline); + } +} diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs new file mode 100644 index 00000000..d22239d9 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs @@ -0,0 +1,54 @@ +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Persistence.Tests.Workspace; + +/// Wipe-ping accessor tests for . +public sealed class WorkspaceStoreWipePingTests +{ + [Fact] + public async Task Ping_defaults_false_without_settings_row() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + var store = new WorkspaceStore(context, new FixedClock(DateTimeOffset.UnixEpoch)); + + Assert.False(await store.GetPingEveryoneOnWipeAsync(10UL)); + } + + [Fact] + public async Task Set_true_then_get_round_trips_and_upserts_row() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + var store = new WorkspaceStore(context, new FixedClock(DateTimeOffset.UnixEpoch)); + + await store.SetPingEveryoneOnWipeAsync(10UL, enabled: true); + + Assert.True(await store.GetPingEveryoneOnWipeAsync(10UL)); + // The upserted row keeps the default culture. + Assert.Equal("en", await store.GetCultureAsync(10UL)); + } + + [Fact] + public async Task Set_preserves_existing_culture() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + var store = new WorkspaceStore(context, new FixedClock(DateTimeOffset.UnixEpoch)); + await store.SetCultureAsync(10UL, "fr"); + + await store.SetPingEveryoneOnWipeAsync(10UL, enabled: true); + + Assert.Equal("fr", await store.GetCultureAsync(10UL)); + Assert.True(await store.GetPingEveryoneOnWipeAsync(10UL)); + } + + private sealed class FixedClock(DateTimeOffset now) : IClock + { + public DateTimeOffset UtcNow { get; } = now; + } +}