From b125ec2496188f83356b0f43a6fb240226740fef Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 22:00:12 +0200 Subject: [PATCH 01/39] Add clan support design spec Design for clan detection, a conditional #clanchat bridge, and a #claninfo channel with anchored embeds plus a live clan-change feed. Introduces a generic workspace capability seam so channels are provisioned only while a clan exists and torn down when it does not. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-21-clan-support-design.md | 426 ++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-21-clan-support-design.md diff --git a/docs/superpowers/specs/2026-07-21-clan-support-design.md b/docs/superpowers/specs/2026-07-21-clan-support-design.md new file mode 100644 index 00000000..610ebb1a --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-clan-support-design.md @@ -0,0 +1,426 @@ +# Clan support — design + +**Date:** 2026-07-21 +**Branch:** `feat/clan-support` +**Status:** approved + +## Problem + +The Rust "clan" update adds a clan system alongside the existing team system. A paired +player may be in a clan or not, and may join or leave one at any time. The bot currently +has no awareness of clans at all. + +We want: + +1. Detection of whether the paired player on a given Rust server is in a clan. +2. A `#clanchat` channel bridging clan chat, mirroring the existing `#teamchat` bridge. +3. A `#claninfo` channel presenting clan data — overview, roster, invites — plus a live + feed of clan changes. +4. Both channels present **only** while a clan exists, and removed when it does not. + +## Capability survey — RustPlusApi 2.0.0-beta.4 + +Verified directly against the shipped assembly and XML docs. Everything the design relies +on already exists; no library change is required. + +**Events on `IRustPlus`:** + +| Event | Payload | Meaning | +| --- | --- | --- | +| `OnClanChatReceived` | `ClanMessageEventArg(ClanId, ClanMessage)` | new clan chat message | +| `OnClanChanged` | `ClanChangedEventArg(ClanInfo?)` | snapshot replaced; `null` ⇒ clan dissolved or left | + +**Requests on `IRustPlus`:** + +| Method | Returns | +| --- | --- | +| `GetClanInfoAsync(ct)` | `ClanInfo` — full snapshot | +| `GetClanChatAsync(ct)` | `ClanChatInfo` — recent messages, oldest-first | +| `SendClanMessageAsync(string, ct)` | posts to clan chat | +| `SetClanMotdAsync(string, ct)` | sets the message of the day | + +**Error code:** `RustPlusErrorCode.NoClan` (`no_clan`) — the player is not in a clan. This is +the authoritative negative-detection signal. + +**Models (`RustPlusApi.Data.Clans`):** + +- `ClanInfo` — `ClanId`, `Name`, `Created`, `Creator`, `Motd`, `MotdTimestamp`, `MotdAuthor`, + `Logo` (raw bytes), `Color` (packed ARGB), `Roles`, `Members`, `Invites`, + `MaxMemberCount`, `Score` +- `ClanMember` — `SteamId`, `RoleId`, `Joined`, `LastSeen`, `Notes`, `Online` +- `ClanRole` — `RoleId`, `Rank` (lower = higher rank), `Name`, and permission flags + `CanSetMotd`, `CanSetLogo`, `CanInvite`, `CanKick`, `CanPromote`, `CanDemote`, + `CanSetPlayerNotes`, `CanAccessLogs`, `CanAccessScoreEvents` +- `ClanInvite` — `SteamId`, `Recruiter`, `Timestamp` +- `ClanMessage` — `SteamId`, `Name`, `Message`, `Time` + +**Deliberately out of scope — no API exists.** `CanAccessLogs` and `CanAccessScoreEvents` +are readable permission flags, but the library exposes no clan audit-log fetch and no +score-event fetch. There is also **no per-member score** — only a single clan-wide +`ClanInfo.Score`. Consequently there is no member-vs-member leaderboard; the roster ranks +on clan role and online status, which is entirely API-derived. Invite / kick / promote / +demote actions are likewise not exposed and are not implemented. Revisit if a later +RustPlusApi release adds them. + +## Scope model + +A clan belongs to the **paired player on a given Rust server**. Clan state is therefore +scoped `(GuildId, ServerId)`, exactly like team state. A guild with three servers may have +a clan on one and none on the others; the channels are per-server, inside the existing +per-server category. + +## Architecture + +New feature project `src/RustPlusBot.Features.Clans`, following the established module +convention: one public `AddClans()` extension at project root, everything else +`internal sealed`, registered from `src/RustPlusBot.Host/Program.cs`, with a matching +`tests/RustPlusBot.Features.Clans.Tests`. + +### Connection seam + +`IRustServerConnection` (`Features.Connections/Listening/`) gains: + +```csharp +Task GetClanInfoAsync(TimeSpan timeout, CancellationToken ct); +Task SendClanMessageAsync(string message, CancellationToken ct); +Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken ct); + +event EventHandler? ClanMessageReceived; +event EventHandler? ClanChanged; +``` + +`GetClanChatAsync` (history) is **not** added: the bridge is live-only, matching teamchat, +and backfilling history on every reconnect would duplicate messages in Discord. + +`RustPlusSocketSource` maps `RustPlusApi` types into RustPlusApi-free records, the same way +`OnTeamChatReceived` is mapped to `TeamChatLine` today: + +- `ClanChatLine(ulong SteamId, string Name, string Message, DateTimeOffset Time)` +- `ClanSnapshot(...)` and its nested `ClanMemberSnapshot` / `ClanRoleSnapshot` / + `ClanInviteSnapshot` records, living in `RustPlusBot.Abstractions/Connections/` + +`GetClanInfoAsync` distinguishes three outcomes: a snapshot, a definitive `NoClan`, and a +transient failure. It returns `ClanProbeResult(ClanSnapshot? Snapshot, ClanProbeStatus Status)` +with `Status ∈ { HasClan, NoClan, Unavailable }` — collapsing `NoClan` and `Unavailable` +into a single `null` would let a socket hiccup delete a user's channels. + +`ConnectionSupervisor`: + +- subscribes to both clan events on connect and unsubscribes on disconnect, alongside the + existing team-chat hookup +- performs one `GetClanInfoAsync` probe on connect, so state is correct after a bot + restart rather than only after the next in-game change +- publishes `ClanMessageReceivedEvent(GuildId, ServerId, SteamId, Name, Message, Time, + FromActivePlayer)` and `ClanStateChangedEvent(GuildId, ServerId, ClanSnapshot?, + ClanProbeStatus)` on `IEventBus` +- implements `IClanChatSender` returning `Sent` / `NotConnected` / `Failed`, mirroring + `ITeamChatSender` + +`RejectedConnection` gets the corresponding no-op implementations. + +### Conditional channels — Workspace capability seam + +The reconciler currently creates every declared channel unconditionally. Rather than +special-casing clans, add a small generic seam: + +```csharp +internal sealed record ChannelSpec( + WorkspaceScope Scope, + string Key, + string NameKey, + ChannelPermissionProfile Permissions, + int Order, + string? Capability = null); + +internal interface IWorkspaceCapabilityProvider +{ + string Capability { get; } + ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken ct); +} +``` + +`WorkspaceRegistry` aggregates all registered providers into a keyed lookup. In +`WorkspaceReconciler.EnsureChannelsAsync`: + +- spec with `Capability == null` — unchanged behaviour +- spec whose capability resolves **available** — created / adopted as today +- spec whose capability resolves **unavailable** — skipped, and any existing + `ProvisionedChannel` for that key is deleted through the existing teardown path so the + `ProvisionedChannel` and dependent `ProvisionedMessage` rows are removed too +- capability with **no registered provider** — treated as unavailable, so a feature that + isn't composed doesn't leave orphan channels + +The existing "retain orphan channels not in the registry" rule is unchanged; capability +removal is an explicit deletion, distinct from a spec disappearing from the registry. + +`Features.Clans` registers `ClanCapabilityProvider` with `Capability = "clan"`, backed by +the persisted clan state (row present ⇒ available). When `ClanStateChangedEvent` flips +presence, `ClansHostedService` calls `ReconcileServerAsync`, so channels appear and +disappear within seconds without a restart. + +**Teardown is triggered only by a definitive signal** — `OnClanChanged` with a `null` +snapshot, or a `GetClanInfoAsync` probe returning `NoClan`. `Unavailable` leaves the last +known state untouched. Losing the Discord-side `#clanchat` history on clan departure is +accepted; there is no grace period. + +### Channel specs + +Added to `Specs/ServerWorkspaceSpecProvider.cs`, immediately after teamchat: + +| Key | Name key | Profile | Order | Capability | +| --- | --- | --- | --- | --- | +| `clanchat` | `channel.clanchat.name` | Interactive | 2 | `clan` | +| `claninfo` | `channel.claninfo.name` | ReadOnly | 3 | `clan` | + +Existing per-server channels shift: events 4, map 5, switches 6, alarms 7, +storagemonitors 8. + +## `#clanchat` + +A deliberate clone of the teamchat bridge, so it inherits its already-proven edge-case +handling. Files mirror `Features.Chat` one-for-one. + +**Game → Discord.** `ClansHostedService` consumes `ClanMessageReceivedEvent` in the +mandated `AlarmsHostedService` loop shape → `ClanChatRelay` → `IClanChatWebhookPoster` / +`DiscordClanChatWebhookPoster` (webhook named `"RustPlusBot ClanChat"`, re-discovered by +name on restart, `ConcurrentDictionary`-cached, `username:` impersonation, +`AllowedMentions.None`). + +`ClanChatRelay` drops, in order: + +1. lines starting with `BotTeamChat.Prefix` (`[R+]`) from the active player +2. echoes matched in the dedup buffer +3. lines starting with the per-server command prefix from `IMuteStore` + +then resolves the channel via `IClanChatChannelLocator` and posts. + +**Discord → game.** `ClanChatInboundProcessor` ignores bot and webhook authors and empty +content, reverse-resolves `(guild, server)` from the channel id, applies the mute gate, +formats `"[{DisplayName}] {Content}"` with `CultureInfo.InvariantCulture`, **records in the +dedup buffer before sending** (the in-game echo can beat the send response), then calls +`IClanChatSender.SendAsync`. A `Failed` outcome reacts ❌ on the Discord message. + +**Dedup buffer.** `RelayDedupBuffer` is currently keyed `(ulong Guild, Guid Server)`. It +gains a `ChatChannelKind { Team, Clan }` discriminator in the key so a clan echo cannot +cancel an identical team-chat line sent in the same 15 s window. The buffer stays a single +shared singleton; the registration test asserting relay and processor share one instance is +extended to cover the clan pair. + +**Locator.** `IClanChatChannelLocator` / `ClanChatChannelLocator` — a subclass of +`CachingChannelLocator` over `WorkspaceChannelKeys.ServerClanChat`, identical in shape to +`TeamChatChannelLocator`, including the reverse `ResolveAsync(channelId)` lookup. + +## `#claninfo` + +Anchored, self-refreshing embeds **and** the live event feed share this channel. Because +Discord orders messages by creation time, feed messages push the anchored embeds upward, so +the reconciler **pins** each anchored clan message on first post. They remain one click away +in the pin bar while the feed flows below. No delete-and-repost churn. + +Pinning is new capability: `MessageSpec` gains a `bool Pinned = false` flag, and +`DiscordWorkspaceGateway` gains `PinMessageAsync(channelId, messageId, ct)`, invoked by +`EnsureMessagesAsync` only when a message is newly posted (pinning is idempotent but costs +an API call, so it is not re-applied on every reconcile). Discord caps a channel at 50 pins; +with three pinned messages that is not a concern. A failed pin is logged and ignored — the +embed is still correct, merely not pinned. + +The declaration-order repair logic in `EnsureMessagesAsync` — which deletes and re-posts +later messages when an earlier one must be newly created — remains correct here, and any +re-posted message is re-pinned. + +The three renderers live in `Features.Clans/Messages/` (data-owning project renders, per the +`ServerEventsMessageRenderer` precedent), are registered scoped, and are spec'd in +Workspace's `ServerWorkspaceSpecProvider`. + +### `clan.overview` + +- title: clan name; embed colour unpacked from `ClanInfo.Color` ARGB, falling back to the + neutral colour when unset +- thumbnail: `Logo` bytes uploaded as an attachment when present, hashed so an unchanged + logo is not re-uploaded +- fields: Score · Members (`n/MaxMemberCount`) · Created (Discord relative timestamp) · + Leader (holder of the lowest-`Rank` role; `Creator` shown separately when different) · + MOTD with author name and relative timestamp +- component: **Set MOTD** button → Discord modal → `SetClanMotdAsync`. Rendered only when + the paired player's own role has `CanSetMotd`; hidden otherwise. Component ids centralised + alongside `WorkspaceComponentIds`. + +### `clan.roster` + +Members grouped by role in ascending `Rank` order (Leader → Officer → Member …), online +members first within each group: + +- 🟢 online / ⚫ offline glyph +- display name (see name resolution below) +- `LastSeen` as a relative timestamp for offline members +- `Joined` date +- officer `Notes` when present + +Each role group's header carries a compact legend of that role's permission flags. + +### `clan.invites` + +Pending invites: invitee, recruiter, and when. Returns an empty `MessagePayload` when there +are none, so the reconciler skips posting it entirely. + +### Name resolution + +`ClanMember` carries only `SteamId` — no display name. A persisted `SteamId → Name` cache +per `(GuildId, ServerId)` is populated from two sources that do carry names: clan chat +message senders, and team snapshots. When a Steam id is unknown, render a +`steamcommunity.com/profiles/{id}` markdown link rather than a bare number — honest about +what we know instead of inventing a placeholder. + +### Refresh + +The three clan message keys are appended to `ServerInfoRefresher`'s key list, reusing the +existing render → canonicalize → `RenderGate.ShouldSend` → edit → commit path, so unchanged +embeds never produce an API call. Refresh is driven by the existing +`ServerInfoRefreshHostedService` interval; no new option is introduced. + +Following the honest-over-stale rule, when the connection is down the overview renders an +explicit "disconnected" embed rather than an empty payload, since an empty payload means +"leave the previous message on screen". + +## Event feed + +`ClanSnapshotDiffer` is a pure function over `(previous, current)` snapshots, producing a +list of typed clan events. It is driven by `ClanStateChangedEvent` and posted as transient +messages into `#claninfo` by `ClanEventRelay`. + +| Event | Trigger | +| --- | --- | +| member joined | new `SteamId` in `Members` | +| member left | `SteamId` gone from `Members` | +| role changed | `RoleId` changed; rendered as promoted or demoted by comparing `Rank` | +| MOTD changed | `Motd` differs; shows new text plus `MotdAuthor` | +| clan renamed | `Name` differs | +| logo changed | logo hash differs | +| colour changed | `Color` differs | +| invite sent | new `SteamId` in `Invites` | +| invite accepted | `SteamId` moves from `Invites` to `Members` | +| invite revoked | `SteamId` leaves `Invites` without joining `Members` | +| score changed | `Score` differs; throttled to at most one post per refresh interval | +| clan dissolved | snapshot becomes `null`; posted before channel teardown | + +The API cannot distinguish a voluntary leave from a kick, so the "member left" string is +deliberately neutral ("X is no longer in the clan") rather than guessing. Likewise +"invite accepted" is inferred from the invite-to-member transition within one diff; if the +two changes land in separate snapshots it degrades to "invite revoked" + "member joined", +which is still accurate. + +Ordering within one diff is fixed and deterministic: dissolved → renamed → MOTD → members → +roles → invites → cosmetic → score. + +## Persistence + +One migration, `ClanSupport`. + +**`ClanState`** — PK `(GuildId, ServerId)`, `ServerId` FK to `RustServer` with +`OnDelete(DeleteBehavior.Cascade)`. + +| Column | Notes | +| --- | --- | +| `ClanId` | clan identifier | +| `Name`, `Created`, `Creator` | identity | +| `Motd`, `MotdAuthor`, `MotdTimestamp` | message of the day | +| `Color`, `LogoHash`, `MaxMemberCount`, `Score` | presentation and stats | +| `MembersJson`, `RolesJson`, `InvitesJson` | serialised collections, for diffing and rendering | +| `LastSeenUtc` | when the snapshot was last confirmed | + +Row presence is the single source of truth for `ClanCapabilityProvider`. + +Collections are stored as JSON rather than normalised tables: they are only ever read and +written whole (diff, then render), are bounded by `MaxMemberCount`, and nothing queries +across them. A normalised schema would add three tables and a migration burden for no +query benefit. + +**`ClanPlayerName`** — PK `(GuildId, ServerId, SteamId)`, columns `Name`, `UpdatedUtc`, +cascade from `RustServer`. + +Both are reached through a scoped `IClanStore` in `RustPlusBot.Persistence`. Singletons +never capture it; they resolve it per call from `IServiceScopeFactory.CreateAsyncScope()`, +per the `TeamChatRelay` pattern. + +## Localization + +Roughly 45 keys, added to **both** `Strings.resx` and `Strings.fr.resx` — the parity test is +a hard CI gate. Namespaces: + +- `channel.clanchat.name`, `channel.claninfo.name` +- `server.clan.overview.*` — title, field labels, MOTD, disconnected state +- `server.clan.roster.*` — role legend, online/offline, last-seen, notes +- `server.clan.invites.*` +- `clan.event.*` — one key per feed event type +- `clan.motd.modal.*` — modal title, field label, success and failure responses + +## Error handling + +- `NoClan` is a **normal state**, never logged as an error and never surfaced to users as a + failure. +- Any other clan RPC failure yields `ClanProbeStatus.Unavailable`, which preserves the last + known state. A transient socket error must never delete a user's clan channels. +- Broad `catch (Exception)` only with an inline `#pragma warning disable CA1031` carrying a + one-line justification, paired with a `[LoggerMessage]`-generated static partial. +- Hosted service loops follow the `AlarmsHostedService` shape verbatim: one `Task.Run` per + event type, `await foreach` over `IEventBus.SubscribeAsync`, `OperationCanceledException` + swallowed. +- `SetClanMotdAsync` failures respond ephemerally in the modal rather than silently + no-opping. + +## Testing + +New project `tests/RustPlusBot.Features.Clans.Tests`, mirroring the source folder structure. +xUnit `Assert.*` + NSubstitute only, no FluentAssertions, `using Xunit` via global using, +`public sealed class XxxTests`, `[Fact] public async Task Snake_case_names()`, private +static `Build(...)` factory returning the SUT plus its substitutes. + +Coverage: + +- `ClanChatRelayTests` — drops bot echo, drops dedup echo, drops command prefix, posts + otherwise +- `ClanChatInboundProcessorTests` — ignores bots and webhooks, mute gate, records dedup + before send, ❌ on failure +- `RelayDedupBufferTests` — extended: a clan echo does not cancel an identical team line +- `ClanSnapshotDifferTests` — one test per event type, plus first-snapshot (no spurious + "joined" storm), no-change, and dissolution +- `ClanRosterRendererTests` — role ordering, online-first, unknown-name fallback link +- `ClanRegistrationTests` — real `ServiceCollection`, `AddClans()`, resolve with + `ValidateScopes = true` +- Workspace: capability-gated channel is created when available, deleted when unavailable, + and untouched when the capability has no provider +- `Features.Connections`: `GetClanInfoAsync` maps `NoClan` to `ClanProbeStatus.NoClan` and + other failures to `Unavailable` + +## Build and CI constraints + +- `-maxcpucount:1` is mandatory on every `dotnet build` and `dotnet test`; read per-assembly + test counts, never just "passed". +- `TreatWarningsAsErrors` with `AnalysisLevel=latest-all`: XML `///` docs on every + public/internal type **and member**; `CultureInfo.InvariantCulture` / `StringComparison.Ordinal` + everywhere; `.ConfigureAwait(false)` on every awaited task in `src/`. +- `dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder"` is a hard gate that + fails on any diff — run once, right before commit. +- Migration: + `dotnet ef migrations add ClanSupport --project src/RustPlusBot.Persistence --startup-project src/RustPlusBot.Host` +- No new Discord permissions or intents: the chat bridge already requires Message Content + Intent and Manage Webhooks. + +## Build sequence + +1. Abstractions: clan snapshot records, `ClanProbeResult`, `ClanMessageReceivedEvent`, + `ClanStateChangedEvent` +2. Connections: `IRustServerConnection` members, `RustPlusSocketSource` mapping, + `RejectedConnection` no-ops, `ConnectionSupervisor` hookup and publishing, + `IClanChatSender` +3. Domain + Persistence: `ClanState`, `ClanPlayerName`, configurations, `IClanStore`, + `ClanSupport` migration +4. Workspace: `Capability` on `ChannelSpec`, `IWorkspaceCapabilityProvider`, registry + aggregation, reconciler create/delete logic, pinning, the two channel specs, the three + message specs, `IClanChatChannelLocator` +5. Features.Clans: chat bridge (relay, poster, inbound processor, hosted service) +6. Features.Clans: `ClanSnapshotDiffer` and `ClanEventRelay` +7. Features.Clans: the three renderers, MOTD modal, `ClanCapabilityProvider`, + `ServerInfoRefresher` key extension +8. Localization: all keys in both resx files +9. Host wiring, full test pass, cleanupcode From fbe030b0b1aceebdc9b38a35813be3e21f6d38a5 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 22:25:48 +0200 Subject: [PATCH 02/39] Add clan support implementation plan Thirteen TDD tasks covering the abstractions, the connection seam, clan persistence, the workspace capability gate, the chat bridge, the snapshot differ, the embeds, the MOTD modal, module wiring, localization and host composition. Also amend the spec to drop the logo thumbnail, which MessagePayload cannot carry, and to record the exact verified API types. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-21-clan-support.md | 3336 +++++++++++++++++ .../specs/2026-07-21-clan-support-design.md | 41 +- 2 files changed, 3364 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-21-clan-support.md diff --git a/docs/superpowers/plans/2026-07-21-clan-support.md b/docs/superpowers/plans/2026-07-21-clan-support.md new file mode 100644 index 00000000..5a1afbe7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-clan-support.md @@ -0,0 +1,3336 @@ +# Clan Support 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 whether a paired player is in a Rust clan and, only while one exists, provision a bidirectional `#clanchat` bridge and a `#claninfo` channel carrying pinned self-refreshing embeds plus a live clan-change feed. + +**Architecture:** A new `RustPlusBot.Features.Clans` module consumes clan events published by the existing `ConnectionSupervisor` over `IEventBus`, persists the latest clan snapshot per `(GuildId, ServerId)`, and diffs consecutive snapshots to emit feed events. Channel conditionality is a new generic capability seam on the Workspace reconciler, not clan-specific branching. The chat bridge is a structural clone of the proven `Features.Chat` team-chat bridge. + +**Tech Stack:** .NET 10, Discord.Net, RustPlusApi 2.0.0-beta.4, EF Core 10 + SQLite, xUnit + NSubstitute, Serilog. + +**Spec:** `docs/superpowers/specs/2026-07-21-clan-support-design.md` + +## Global Constraints + +Every task's requirements implicitly include this section. + +- Solution is `RustPlusBot.slnx`. There is no `.sln`. +- **`-maxcpucount:1` is MANDATORY on every `dotnet build` and every `dotnet test`.** A `ConfigureGitHooks` target races on `.git/config` under parallel builds; a failed build silently drops an assembly's tests so they report 0 and look like they passed. Always read per-assembly test counts, never just "passed". +- Run `dotnet tool restore` once before the first build. +- Build is `-warnaserror` with `AnalysisLevel=latest-all`. XML `///` docs are required on **every** public **and internal** type *and member*, including record positional parameters (`/// `). +- `CA1305`/`CA1307`/`CA1310`: pass `CultureInfo.InvariantCulture` on every format/parse and `StringComparison.Ordinal` on every string comparison. +- `CA2007`: `.ConfigureAwait(false)` on every awaited task in `src/`. Test projects are exempt. +- Broad `catch (Exception)` is allowed ONLY with an inline `#pragma warning disable CA1031` carrying a one-line justification comment, paired with a `[LoggerMessage]`-generated `static partial` log method. +- Tests: plain xUnit `Assert.*` + NSubstitute. **No FluentAssertions.** `using Xunit` is a global using — never add it per file. Tests do not need `.ConfigureAwait(false)`. +- Every new string key MUST be added to **both** `src/RustPlusBot.Localization/Strings.resx` and `Strings.fr.resx`. `StringsResourceParityTests` fails the build otherwise. +- `dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder"` is a hard CI gate that fails on any diff. It is slow — run it ONCE at the very end (Task 13), not per task. +- Commit messages: plain imperative sentences, **no `feat:`/`fix:` prefixes**, each ending with a `Co-Authored-By: Claude Opus 4.8 (1M context) ` trailer. +- Branch: `feat/clan-support`, off `develop`. +- Singletons must NEVER capture a scoped `I*Store`. Resolve per call from `IServiceScopeFactory.CreateAsyncScope()`. +- Every feature module exposes exactly one public `Add()` extension at project root; everything else is `internal sealed`. + +## Verified API facts + +These were confirmed by reflecting over `RustPlusApi 2.0.0-beta.4`. Do not re-derive or assume otherwise. + +``` +ClanInfo : long ClanId, string Name, DateTime Created, ulong Creator, + string? Motd, DateTime? MotdTimestamp, ulong? MotdAuthor, + byte[]? Logo, int? Color, IEnumerable Roles, + IEnumerable Members, IEnumerable Invites, + int? MaxMemberCount, long? Score +ClanMember : ulong SteamId, int RoleId, DateTime Joined, DateTime LastSeen, + string? Notes, bool? Online <-- NULLABLE +ClanRole : int RoleId, int Rank, string Name, bool CanSetMotd, CanSetLogo, + CanInvite, CanKick, CanPromote, CanDemote, CanSetPlayerNotes, + CanAccessLogs, CanAccessScoreEvents +ClanInvite : ulong SteamId, ulong Recruiter, DateTime Timestamp +ClanMessageEventArg : long ClanId, ulong SteamId, string Name, string Message, DateTime Time (FLAT) +ClanChangedEventArg : ClanInfo? ClanInfo (null => dissolved/left) + +IRustPlus.GetClanInfoAsync(ct) -> Task> +IRustPlus.SendClanMessageAsync(msg, ct) -> Task +IRustPlus.SetClanMotdAsync(motd, ct) -> Task +IRustPlus.OnClanChatReceived -> EventHandler +IRustPlus.OnClanChanged -> EventHandler +RustPlusErrorCode.NoClan -> "no_clan" + +Response : bool IsSuccess, T? Data, ErrorMessage? Error (Error.Code is RustPlusErrorCode) +``` + +Lower `ClanRole.Rank` = higher rank (leader is the lowest number). + +## File Structure + +**New project `src/RustPlusBot.Features.Clans/`** + +| File | Responsibility | +| --- | --- | +| `ClansServiceCollectionExtensions.cs` | The single public `AddClans()` | +| `Hosting/ClansHostedService.cs` | Two event-bus consumer loops + the Discord `MessageReceived` hook | +| `Relaying/ClanChatRelay.cs` | Game→Discord: drop echoes/commands, post via webhook | +| `Webhooks/IClanChatWebhookPoster.cs` / `DiscordClanChatWebhookPoster.cs` | Webhook impersonation | +| `Inbound/ClanChatInboundProcessor.cs` | Discord→game: mute gate, record-then-send | +| `State/ClanStateService.cs` | Applies a `ClanStateChangedEvent`: persist, diff, trigger reconcile | +| `State/ClanSnapshotDiffer.cs` | Pure `(previous, current) -> IReadOnlyList` | +| `State/ClanChange.cs` | The typed change record + `ClanChangeKind` enum | +| `State/ClanCapabilityProvider.cs` | `IWorkspaceCapabilityProvider` for `"clan"` | +| `Names/IClanNameResolver.cs` / `ClanNameResolver.cs` | Steam id → display name, with profile-link fallback | +| `Messages/ClanOverviewMessageRenderer.cs` | `clan.overview` embed + Set MOTD button | +| `Messages/ClanRosterMessageRenderer.cs` | `clan.roster` embed | +| `Messages/ClanInvitesMessageRenderer.cs` | `clan.invites` embed (empty when none) | +| `Messages/ClanChangeRenderer.cs` | Renders one `ClanChange` to feed text | +| `Modules/ClanMotdModule.cs` | Button + modal interaction handler | +| `ClanComponentIds.cs` | Component id constants | + +**Modified — Abstractions** + +| File | Change | +| --- | --- | +| `Connections/ClanSnapshot.cs` (new) | `ClanSnapshot`, `ClanMemberSnapshot`, `ClanRoleSnapshot`, `ClanInviteSnapshot` | +| `Connections/ClanProbeResult.cs` (new) | `ClanProbeResult` + `ClanProbeStatus` | +| `Events/ClanMessageReceivedEvent.cs` (new) | Game→Discord relay event | +| `Events/ClanStateChangedEvent.cs` (new) | Snapshot-changed event | + +**Modified — Connections, Domain, Persistence, Workspace, Localization, Host** — enumerated per task. + +**New test project `tests/RustPlusBot.Features.Clans.Tests/`** mirroring the source layout. + +--- + +### Task 1: Clan snapshot records and events (Abstractions) + +Pure data types with no dependencies. Nothing to unit test in isolation — these are records — so this task's gate is that the solution still builds with docs and analyzers clean. + +**Files:** +- Create: `src/RustPlusBot.Abstractions/Connections/ClanSnapshot.cs` +- Create: `src/RustPlusBot.Abstractions/Connections/ClanProbeResult.cs` +- Create: `src/RustPlusBot.Abstractions/Events/ClanMessageReceivedEvent.cs` +- Create: `src/RustPlusBot.Abstractions/Events/ClanStateChangedEvent.cs` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ClanSnapshot`, `ClanMemberSnapshot`, `ClanRoleSnapshot`, `ClanInviteSnapshot`, `ClanProbeResult`, `ClanProbeStatus`, `ClanMessageReceivedEvent`, `ClanStateChangedEvent`. Every later task depends on these exact shapes. + +- [ ] **Step 1: Create the snapshot records** + +`src/RustPlusBot.Abstractions/Connections/ClanSnapshot.cs`: + +```csharp +namespace RustPlusBot.Abstractions.Connections; + +/// A permission role within a clan. +/// Unique id of the role within the clan. +/// Rank order; LOWER values are HIGHER ranks (0 is the leader). +/// Display name of the role. +/// True when holders may set the clan MOTD. +/// True when holders may set the clan logo. +/// True when holders may invite players. +/// True when holders may kick members. +/// True when holders may promote members. +/// True when holders may demote members. +/// True when holders may set notes on members. +/// True when holders may view clan audit logs. +/// True when holders may view clan score events. +public sealed record ClanRoleSnapshot( + int RoleId, + int Rank, + string Name, + bool CanSetMotd, + bool CanSetLogo, + bool CanInvite, + bool CanKick, + bool CanPromote, + bool CanDemote, + bool CanSetPlayerNotes, + bool CanAccessLogs, + bool CanAccessScoreEvents); + +/// A member of a clan. +/// Steam64 id of the member. +/// Id of the role assigned to this member. +/// When the member joined the clan (UTC). +/// When the member was last seen online (UTC). +/// Officer notes attached to this member, or null. +/// True when the member is currently online. The API reports this as nullable; null is mapped to false. +public sealed record ClanMemberSnapshot( + ulong SteamId, + int RoleId, + DateTimeOffset Joined, + DateTimeOffset LastSeen, + string? Notes, + bool Online); + +/// A pending invitation to join a clan. +/// Steam64 id of the invited player. +/// Steam64 id of the member who sent the invitation. +/// When the invitation was created (UTC). +public sealed record ClanInviteSnapshot(ulong SteamId, ulong Recruiter, DateTimeOffset Timestamp); + +/// A full clan snapshot, decoupled from RustPlusApi types. +/// Unique identifier of the clan. +/// Display name of the clan. +/// When the clan was created (UTC). +/// Steam64 id of the clan creator. +/// Message of the day, or null when unset. +/// When the MOTD was last changed (UTC), or null. +/// Steam64 id of the player who last changed the MOTD, or null. +/// Stable hash of the clan logo bytes, or null when no logo is set. The bytes themselves are not carried: nothing renders them. +/// Clan colour as a packed ARGB integer, or null. +/// Maximum members allowed, or null when uncapped. +/// Clan score, or null when the server does not report one. +/// Roles defined in this clan. +/// Current members of the clan. +/// Pending invitations to the clan. +public sealed record ClanSnapshot( + long ClanId, + string Name, + DateTimeOffset Created, + ulong Creator, + string? Motd, + DateTimeOffset? MotdTimestamp, + ulong? MotdAuthor, + string? LogoHash, + int? Color, + int? MaxMemberCount, + long? Score, + IReadOnlyList Roles, + IReadOnlyList Members, + IReadOnlyList Invites); +``` + +- [ ] **Step 2: Create the probe result** + +`src/RustPlusBot.Abstractions/Connections/ClanProbeResult.cs`: + +```csharp +namespace RustPlusBot.Abstractions.Connections; + +/// The outcome of asking a live socket for its clan snapshot. +public enum ClanProbeStatus +{ + /// The player is in a clan and the snapshot is populated. + HasClan = 0, + + /// The server answered definitively that the player is in no clan. + NoClan = 1, + + /// The request failed or timed out; the previous known state must be preserved. + Unavailable = 2, +} + +/// +/// A clan probe outcome. and +/// are deliberately distinct: collapsing them into a +/// single null snapshot would let a transient socket failure tear down a guild's clan channels. +/// +/// What the server said. +/// The snapshot; non-null if and only if is . +public sealed record ClanProbeResult(ClanProbeStatus Status, ClanSnapshot? Snapshot) +{ + /// A probe that definitively reported no clan. + public static ClanProbeResult NoClan { get; } = new(ClanProbeStatus.NoClan, null); + + /// A probe that failed; the caller must preserve the last known state. + public static ClanProbeResult Unavailable { get; } = new(ClanProbeStatus.Unavailable, null); + + /// Creates a successful probe carrying . + /// The clan snapshot returned by the server. + /// A result. + public static ClanProbeResult From(ClanSnapshot snapshot) => new(ClanProbeStatus.HasClan, snapshot); +} +``` + +- [ ] **Step 3: Create the two bus events** + +`src/RustPlusBot.Abstractions/Events/ClanMessageReceivedEvent.cs`: + +```csharp +namespace RustPlusBot.Abstractions.Events; + +/// Published when an in-game clan chat line is received, so the bridge can relay it to Discord. +/// The owning guild snowflake. +/// The server whose socket received the line. +/// The Steam64 id of the in-game sender. +/// The in-game display name of the sender. +/// The message text. +/// True when the sender is the bot's active player (used to drop relay echoes). +public sealed record ClanMessageReceivedEvent( + ulong GuildId, + Guid ServerId, + ulong SenderSteamId, + string SenderName, + string Message, + bool FromActivePlayer); +``` + +`src/RustPlusBot.Abstractions/Events/ClanStateChangedEvent.cs`: + +```csharp +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Abstractions.Events; + +/// +/// Published when a server's clan snapshot may have changed — on connect (probe) and on every +/// in-game clan change. Carries the probe status so consumers can tell "definitely no clan" +/// apart from "could not ask". +/// +/// The owning guild snowflake. +/// The server the snapshot belongs to. +/// Whether the player has a clan, has none, or could not be asked. +/// The new snapshot; non-null if and only if is . +public sealed record ClanStateChangedEvent( + ulong GuildId, + Guid ServerId, + ClanProbeStatus Status, + ClanSnapshot? Snapshot); +``` + +- [ ] **Step 4: Build and verify clean** + +Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. Any missing-XML-doc warning is an error here — fix it before continuing. + +- [ ] **Step 5: Commit** + +```bash +git add src/RustPlusBot.Abstractions/Connections/ClanSnapshot.cs \ + src/RustPlusBot.Abstractions/Connections/ClanProbeResult.cs \ + src/RustPlusBot.Abstractions/Events/ClanMessageReceivedEvent.cs \ + src/RustPlusBot.Abstractions/Events/ClanStateChangedEvent.cs +git commit -m "$(cat <<'EOF' +Add clan snapshot records and bus events + +Introduce the RustPlusApi-free clan snapshot shapes, the three-state clan +probe result, and the two event-bus events the clan feature consumes. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 2: Clan mapping and the connection seam (Connections) + +`RustPlusSocketSource` is an untested integration shim, so all interpretable logic goes into a testable static `ClanMapping` — exactly how `ReachabilityMapping` / `ReachabilityMappingTests` already work in this project. + +**Files:** +- Create: `src/RustPlusBot.Features.Connections/Listening/ClanMapping.cs` +- Create: `src/RustPlusBot.Features.Connections/Listening/ClanChatLine.cs` +- Create: `src/RustPlusBot.Features.Connections/Listening/IClanChatSender.cs` +- Modify: `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs` +- Modify: `src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs` +- Test: `tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs` +- Modify: `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs` + +**Interfaces:** +- Consumes: `ClanSnapshot`, `ClanMemberSnapshot`, `ClanRoleSnapshot`, `ClanInviteSnapshot`, `ClanProbeResult`, `ClanProbeStatus` (Task 1). +- Produces: + - `internal static class ClanMapping` with `public static ClanProbeResult FromResponse(bool isSuccess, RustPlusErrorCode? errorCode, ClanInfo? data)` and `public static ClanSnapshot ToSnapshot(ClanInfo info)` and `public static string HashLogo(byte[]? logo)`. + - `internal sealed record ClanChatLine(ulong SteamId, string Name, string Message, DateTimeOffset Time)`. + - `public enum ClanChatSendResult { Sent = 0, NotConnected = 1, Failed = 2 }` and `public interface IClanChatSender { Task SendAsync(ulong guildId, Guid serverId, string message, CancellationToken cancellationToken); }`. + - New `IRustServerConnection` members: `Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken)`, `Task SendClanMessageAsync(string message, CancellationToken cancellationToken)`, `Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken)`, `event EventHandler? ClanMessageReceived`, `event EventHandler? ClanChanged`. + +**Note on `ClanChanged`:** the event carries `ClanProbeResult`, not `ClanSnapshot?`. `OnClanChanged` with a null `ClanInfo` maps to `ClanProbeResult.NoClan` — a definitive signal — which keeps a single type flowing from socket to supervisor to bus. + +- [ ] **Step 1: Write the failing tests** + +`tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs`: + +```csharp +using RustPlusApi.Data; +using RustPlusApi.Data.Clans; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class ClanMappingTests +{ + [Fact] + public void Maps_no_clan_error_to_NoClan() + { + var result = ClanMapping.FromResponse(false, RustPlusErrorCode.NoClan, null); + + Assert.Equal(ClanProbeStatus.NoClan, result.Status); + Assert.Null(result.Snapshot); + } + + [Fact] + public void Maps_any_other_error_to_Unavailable() + { + var result = ClanMapping.FromResponse(false, RustPlusErrorCode.NotFound, null); + + Assert.Equal(ClanProbeStatus.Unavailable, result.Status); + } + + [Fact] + public void Maps_a_missing_error_code_to_Unavailable() + { + var result = ClanMapping.FromResponse(false, null, null); + + Assert.Equal(ClanProbeStatus.Unavailable, result.Status); + } + + [Fact] + public void Maps_success_with_null_payload_to_Unavailable() + { + // A success carrying no data is not evidence of "no clan"; preserve the last known state. + var result = ClanMapping.FromResponse(true, null, null); + + Assert.Equal(ClanProbeStatus.Unavailable, result.Status); + } + + [Fact] + public void Maps_a_populated_clan_to_a_snapshot() + { + var info = new ClanInfo + { + ClanId = 42, + Name = "Wolves", + Created = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + Creator = 7UL, + Motd = "hold the line", + MotdTimestamp = new DateTime(2026, 2, 2, 0, 0, 0, DateTimeKind.Utc), + MotdAuthor = 9UL, + Logo = [1, 2, 3], + Color = 255, + MaxMemberCount = 8, + Score = 1234, + Roles = [new ClanRole { RoleId = 1, Rank = 0, Name = "Leader", CanSetMotd = true }], + Members = + [ + new ClanMember + { + SteamId = 7UL, + RoleId = 1, + Joined = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + LastSeen = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), + Notes = "founder", + Online = true, + }, + ], + Invites = [new ClanInvite { SteamId = 11UL, Recruiter = 7UL, Timestamp = new DateTime(2026, 3, 2, 0, 0, 0, DateTimeKind.Utc) }], + }; + + var result = ClanMapping.FromResponse(true, null, info); + + Assert.Equal(ClanProbeStatus.HasClan, result.Status); + var snapshot = Assert.IsType(result.Snapshot); + Assert.Equal(42L, snapshot.ClanId); + Assert.Equal("Wolves", snapshot.Name); + Assert.Equal(7UL, snapshot.Creator); + Assert.Equal(1234L, snapshot.Score); + Assert.Equal(8, snapshot.MaxMemberCount); + Assert.Single(snapshot.Roles); + Assert.True(snapshot.Roles[0].CanSetMotd); + Assert.Single(snapshot.Members); + Assert.True(snapshot.Members[0].Online); + Assert.Equal("founder", snapshot.Members[0].Notes); + Assert.Single(snapshot.Invites); + Assert.Equal(11UL, snapshot.Invites[0].SteamId); + Assert.NotNull(snapshot.LogoHash); + } + + [Fact] + public void Treats_a_null_Online_flag_as_offline() + { + // ClanMember.Online is bool? in the API; null must not be rendered as online. + var info = NewClan(members: [new ClanMember { SteamId = 7UL, RoleId = 1, Online = null }]); + + var result = ClanMapping.FromResponse(true, null, info); + + Assert.False(result.Snapshot!.Members[0].Online); + } + + [Fact] + public void Treats_timestamps_as_utc() + { + var info = NewClan(); + info.Created = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Unspecified); + + var result = ClanMapping.FromResponse(true, null, info); + + Assert.Equal(TimeSpan.Zero, result.Snapshot!.Created.Offset); + Assert.Equal(new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), result.Snapshot.Created.UtcDateTime); + } + + [Fact] + public void Hashes_a_missing_logo_to_null_and_equal_logos_to_equal_hashes() + { + Assert.Null(ClanMapping.HashLogo(null)); + Assert.Null(ClanMapping.HashLogo([])); + Assert.Equal(ClanMapping.HashLogo([1, 2, 3]), ClanMapping.HashLogo([1, 2, 3])); + Assert.NotEqual(ClanMapping.HashLogo([1, 2, 3]), ClanMapping.HashLogo([3, 2, 1])); + } + + private static ClanInfo NewClan(IEnumerable? members = null) => + new() + { + ClanId = 1, + Name = "C", + Creator = 1UL, + Roles = [], + Members = members ?? [], + Invites = [], + }; +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1 --filter FullyQualifiedName~ClanMappingTests` +Expected: FAIL — compile error, `ClanMapping` does not exist. + +- [ ] **Step 3: Implement `ClanMapping`** + +`src/RustPlusBot.Features.Connections/Listening/ClanMapping.cs`: + +```csharp +using System.Globalization; +using System.Security.Cryptography; +using RustPlusApi.Data; +using RustPlusApi.Data.Clans; +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Features.Connections.Listening; + +/// +/// Maps RustPlusApi clan types onto the bot's RustPlusApi-free snapshots. The ONLY place clan +/// protobuf models and are interpreted. +/// +internal static class ClanMapping +{ + /// + /// Classifies a clan-info response. no_clan is a definitive negative; every other + /// failure — including a success carrying no payload — is , + /// so a transient fault never looks like "the player left their clan". + /// + /// Whether the Rust+ response succeeded. + /// The error code if the response failed, or null. + /// The response payload, or null. + /// The classified probe result. + public static ClanProbeResult FromResponse(bool isSuccess, RustPlusErrorCode? errorCode, ClanInfo? data) + { + if (isSuccess) + { + return data is null ? ClanProbeResult.Unavailable : ClanProbeResult.From(ToSnapshot(data)); + } + + return errorCode == RustPlusErrorCode.NoClan ? ClanProbeResult.NoClan : ClanProbeResult.Unavailable; + } + + /// Converts a RustPlusApi clan payload into the bot's snapshot shape. + /// The clan payload to convert. + /// The equivalent . + public static ClanSnapshot ToSnapshot(ClanInfo info) + { + ArgumentNullException.ThrowIfNull(info); + + return new ClanSnapshot( + info.ClanId, + info.Name ?? string.Empty, + Utc(info.Created), + info.Creator, + info.Motd, + info.MotdTimestamp is { } motdAt ? Utc(motdAt) : null, + info.MotdAuthor, + HashLogo(info.Logo), + info.Color, + info.MaxMemberCount, + info.Score, + [ + .. (info.Roles ?? []).Select(r => new ClanRoleSnapshot( + r.RoleId, r.Rank, r.Name ?? string.Empty, r.CanSetMotd, r.CanSetLogo, r.CanInvite, + r.CanKick, r.CanPromote, r.CanDemote, r.CanSetPlayerNotes, r.CanAccessLogs, + r.CanAccessScoreEvents)), + ], + [ + .. (info.Members ?? []).Select(m => new ClanMemberSnapshot( + m.SteamId, m.RoleId, Utc(m.Joined), Utc(m.LastSeen), m.Notes, m.Online ?? false)), + ], + [ + .. (info.Invites ?? []).Select(i => new ClanInviteSnapshot( + i.SteamId, i.Recruiter, Utc(i.Timestamp))), + ]); + } + + /// + /// Hashes the clan logo bytes so a change can be detected without carrying or storing the image. + /// Not a security boundary — SHA-256 is used purely as a stable content fingerprint. + /// + /// The raw logo bytes, or null. + /// A lowercase hex digest, or null when there is no logo. + public static string? HashLogo(byte[]? logo) + { + if (logo is null || logo.Length == 0) + { + return null; + } + + return Convert.ToHexString(SHA256.HashData(logo)).ToLowerInvariant(); + } + + /// Reinterprets an API timestamp as UTC (the API documents all clan timestamps as UTC). + /// The timestamp to normalise. + /// The equivalent at zero offset. + private static DateTimeOffset Utc(DateTime value) => + new(DateTime.SpecifyKind(value, DateTimeKind.Utc), TimeSpan.Zero); +} +``` + +Note: `CultureInfo` is imported for consistency with sibling files; if the analyzer reports it unused, delete the `using`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1 --filter FullyQualifiedName~ClanMappingTests` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Add the chat line and sender seam** + +`src/RustPlusBot.Features.Connections/Listening/ClanChatLine.cs`: + +```csharp +namespace RustPlusBot.Features.Connections.Listening; + +/// A raw clan chat line as received from a socket (before active-player classification). +/// The Steam64 id of the sender. +/// The in-game display name of the sender. +/// The message text. +/// When the line was sent (UTC). +internal sealed record ClanChatLine(ulong SteamId, string Name, string Message, DateTimeOffset Time); +``` + +`src/RustPlusBot.Features.Connections/Listening/IClanChatSender.cs`: + +```csharp +namespace RustPlusBot.Features.Connections.Listening; + +/// The outcome of relaying a Discord message into in-game clan chat. +public enum ClanChatSendResult +{ + /// The message was handed to the live socket. + Sent = 0, + + /// There is no live socket for that (guild, server) right now. + NotConnected = 1, + + /// A live socket exists but the send failed. + Failed = 2, +} + +/// Relays a message into a server's in-game clan chat (implemented by the connection supervisor). +public interface IClanChatSender +{ + /// Sends to the live socket for (, ). + /// The owning guild snowflake. + /// The target server id. + /// The message text to relay. + /// A cancellation token. + /// The send result. + Task SendAsync(ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken); +} +``` + +- [ ] **Step 6: Extend `IRustServerConnection`** + +Append these members to `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs`, immediately after `SendTeamMessageAsync`: + +```csharp + /// Probes the authenticated player's clan, distinguishing "no clan" from "could not ask". + /// How long to wait for the response. + /// A cancellation token. + /// The classified probe result. + Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken); + + /// Sends a message to in-game clan chat. + /// The message text to send. + /// A cancellation token. + /// A task that completes when the send has been issued. + /// Like , this surfaces failures to the caller. + Task SendClanMessageAsync(string message, CancellationToken cancellationToken); + + /// Sets the clan message of the day; returns true on success. + /// The new message of the day. + /// How long to wait for the response. + /// A cancellation token. + /// True if the MOTD was set; false on failure/timeout. + Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken); +``` + +and these events after `TeamMessageReceived`: + +```csharp + /// Raised for every in-game clan chat line received on this socket. + event EventHandler? ClanMessageReceived; + + /// + /// Raised when the clan snapshot changes in game. A dissolved or departed clan arrives as + /// — a definitive signal, never . + /// + event EventHandler? ClanChanged; +``` + +- [ ] **Step 7: Implement both `IRustServerConnection` implementations** + +In `RejectedConnection` (`RustPlusSocketSource.cs`), add after `SendTeamMessageAsync`: + +```csharp + public Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(ClanProbeResult.Unavailable); + + public Task SendClanMessageAsync(string message, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(false); +``` + +and after the `TeamMessageReceived` event stub: + +```csharp + public event EventHandler? ClanMessageReceived + { + add { _ = value; } + remove { _ = value; } + } + + public event EventHandler? ClanChanged + { + add { _ = value; } + remove { _ = value; } + } +``` + +A rejected credential returns `Unavailable`, never `NoClan`: it never asked, so it must not cause a teardown. + +In `RustPlusServerConnection`, subscribe in the constructor alongside the existing handlers: + +```csharp + _rustPlus.OnClanChatReceived += OnClanChatReceived; + _rustPlus.OnClanChanged += OnClanChanged; +``` + +and add the members (place them next to `SendTeamMessageAsync`): + +```csharp + public event EventHandler? ClanMessageReceived; + + public event EventHandler? ClanChanged; + + public async Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + // CONFIRMED (2.0.0-beta.4): GetClanInfoAsync returns Task>; + // Response.IsSuccess / .Data / .Error?.Code are the accessors. + var response = await _rustPlus.GetClanInfoAsync(timeoutCts.Token).ConfigureAwait(false); + return ClanMapping.FromResponse(response.IsSuccess, response.Error?.Code, response.Data); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return ClanProbeResult.Unavailable; + } +#pragma warning disable CA1031 // Broad catch: a failed clan probe must degrade to Unavailable, never crash the caller. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogQueryFailed(_logger, ex); + return ClanProbeResult.Unavailable; + } + } + + public async Task SendClanMessageAsync(string message, CancellationToken cancellationToken) + { + // Intentional: send failures propagate to the caller (the supervisor classifies them). + await _rustPlus.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false); + } + + public async Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + var response = await _rustPlus.SetClanMotdAsync(motd, timeoutCts.Token).ConfigureAwait(false); + return response.IsSuccess; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } +#pragma warning disable CA1031 // Broad catch: a failed MOTD write is reported to the user, not thrown. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogQueryFailed(_logger, ex); + return false; + } + } + + private void OnClanChatReceived(object? sender, ClanMessageEventArg e) => + ClanMessageReceived?.Invoke(this, + new ClanChatLine(e.SteamId, e.Name ?? string.Empty, e.Message ?? string.Empty, + new DateTimeOffset(DateTime.SpecifyKind(e.Time, DateTimeKind.Utc), TimeSpan.Zero))); + + private void OnClanChanged(object? sender, ClanChangedEventArg e) => + ClanChanged?.Invoke(this, + e.ClanInfo is { } info ? ClanProbeResult.From(ClanMapping.ToSnapshot(info)) : ClanProbeResult.NoClan); +``` + +Add `using RustPlusApi.Data.Events;` to the file's usings. + +- [ ] **Step 8: Update the test fake** + +`FakeRustSocketSource.cs` implements `IRustServerConnection`. Add to its connection class: + +```csharp + /// The probe result this fake returns; defaults to no clan. + public ClanProbeResult ClanProbe { get; set; } = ClanProbeResult.NoClan; + + /// Messages sent to in-game clan chat through this fake. + public List SentClanMessages { get; } = []; + + /// + public Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(ClanProbe); + + /// + public Task SendClanMessageAsync(string message, CancellationToken cancellationToken) + { + SentClanMessages.Add(message); + return Task.CompletedTask; + } + + /// + public Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(true); + + /// + public event EventHandler? ClanMessageReceived; + + /// + public event EventHandler? ClanChanged; + + /// Raises for a test. + /// The line to raise. + public void RaiseClanMessage(ClanChatLine line) => ClanMessageReceived?.Invoke(this, line); + + /// Raises for a test. + /// The probe result to raise. + public void RaiseClanChanged(ClanProbeResult result) => ClanChanged?.Invoke(this, result); +``` + +Match the fake's existing naming and access modifiers; if it declares members without `///` docs (test projects still require them under `GenerateDocumentationFile`), follow whatever the surrounding file does. + +- [ ] **Step 9: Build and run the full Connections suite** + +Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `0 Error(s)`. + +Run: `dtk dotnet test tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1` +Expected: all pass, and the total count is 8 higher than before this task. + +- [ ] **Step 10: Commit** + +```bash +git add src/RustPlusBot.Features.Connections tests/RustPlusBot.Features.Connections.Tests +git commit -m "$(cat <<'EOF' +Map clan responses and extend the connection seam + +Add ClanMapping, which is the only place clan protobuf models and +RustPlusErrorCode are interpreted, and extend IRustServerConnection with the +clan probe, clan send, MOTD write, and the two clan events. no_clan maps to a +definitive NoClan; every other failure degrades to Unavailable so a transient +fault cannot look like a departed clan. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 3: Publish clan events from the supervisor (Connections) + +**Files:** +- Modify: `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs` +- Modify: `src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs` +- Test: `tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs` + +**Interfaces:** +- Consumes: `ClanChatLine`, `ClanProbeResult`, `IClanChatSender`, `ClanChatSendResult` (Task 2); `ClanMessageReceivedEvent`, `ClanStateChangedEvent` (Task 1). +- Produces: `ConnectionSupervisor` additionally implements `IClanChatSender`; publishes `ClanMessageReceivedEvent` and `ClanStateChangedEvent` on `IEventBus`. + +Read `ConnectionSupervisorTests.cs` first — it already establishes how to stand up a supervisor over `FakeRustSocketSource` and drain the bus. Reuse that harness rather than inventing one. + +- [ ] **Step 1: Write the failing tests** + +`tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs`. Model the `Build`/run harness on the existing `ConnectionSupervisorTests`; the assertions that matter are: + +```csharp +// 1. On connect, the supervisor probes the clan and publishes the result. +[Fact] public async Task Publishes_a_clan_state_event_on_connect() +// Arrange the fake connection with ClanProbe = ClanProbeResult.From(snapshot). +// Assert a ClanStateChangedEvent with Status == HasClan and the same ClanId reaches the bus. + +// 2. A NoClan probe still publishes, so the state service can tear down. +[Fact] public async Task Publishes_NoClan_on_connect_when_the_player_has_no_clan() + +// 3. An in-game clan change is forwarded. +[Fact] public async Task Publishes_a_clan_state_event_when_the_socket_reports_a_change() +// Call fake.RaiseClanChanged(ClanProbeResult.NoClan); assert Status == NoClan reaches the bus. + +// 4. Clan chat lines are published with active-player classification. +[Fact] public async Task Publishes_clan_messages_and_flags_the_active_player() +// RaiseClanMessage with SteamId == the credential's steam id => FromActivePlayer true; +// with a different SteamId => false. + +// 5. Sending with no live socket reports NotConnected. +[Fact] public async Task Clan_send_reports_NotConnected_without_a_live_socket() +// var result = await supervisor.SendAsync(guildId, Guid.NewGuid(), "hi", default); +// Assert.Equal(ClanChatSendResult.NotConnected, result); + +// 6. Sending with a live socket reaches the connection. +[Fact] public async Task Clan_send_reaches_the_live_socket() +// Assert fake.SentClanMessages contains the text and the result is Sent. + +// 7. Unsubscription on disconnect: after the connection loop exits, raising a clan +// event on the fake publishes nothing further. +[Fact] public async Task Stops_publishing_clan_events_after_the_socket_closes() +``` + +Write these out fully against the existing harness. Do not leave them as comments. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1 --filter FullyQualifiedName~ClanSupervisorTests` +Expected: FAIL — `SendAsync` returning `ClanChatSendResult` does not exist; no clan events are published. + +- [ ] **Step 3: Implement `IClanChatSender` on the supervisor** + +Declare `IClanChatSender` on the class alongside `ITeamChatSender`. Because `ITeamChatSender.SendAsync` and `IClanChatSender.SendAsync` have identical parameter lists and differ only in return type, C# cannot host both as implicit implementations. Implement the clan one **explicitly**: + +```csharp + /// + async Task IClanChatSender.SendAsync( + ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return ClanChatSendResult.NotConnected; + } + + try + { + await live.Connection.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false); + return ClanChatSendResult.Sent; + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogClanSendFailed(logger, ex, serverId); + return ClanChatSendResult.Failed; + } + } +``` + +Because it is explicit, the tests must call it through the interface: `((IClanChatSender)supervisor).SendAsync(...)`. + +- [ ] **Step 4: Subscribe, probe, and publish in the connected window** + +In the method that wires `connection.TeamMessageReceived += OnTeamMessage` (around `ConnectionSupervisor.cs:551`), add local handlers next to the existing ones: + +```csharp +#pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler delegate shape. + void OnClanMessage(object? sender, ClanChatLine line) + { + _ = PublishClanMessageAsync(key, activeSteamId, line); + } +#pragma warning restore RCS1163 + +#pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler delegate shape. + void OnClanChanged(object? sender, ClanProbeResult probe) + { + _ = PublishClanStateAsync(key, probe); + } +#pragma warning restore RCS1163 +``` + +Subscribe alongside the existing three: + +```csharp + connection.ClanMessageReceived += OnClanMessage; + connection.ClanChanged += OnClanChanged; +``` + +Unsubscribe in the same `finally` block as the existing three: + +```csharp + connection.ClanMessageReceived -= OnClanMessage; + connection.ClanChanged -= OnClanChanged; +``` + +Immediately after `await PrimeDevicesAsync(key, connection, ct)`, add the connect-time probe: + +```csharp + // Probe once on connect so clan state is correct after a bot restart, not only after the + // next in-game change. An Unavailable result publishes too: the consumer preserves state. + var clanProbe = await connection.GetClanInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); + await PublishClanStateAsync(key, clanProbe).ConfigureAwait(false); +``` + +- [ ] **Step 5: Add the two publish helpers** + +Place these next to `PublishTeamMessageAsync`: + +```csharp + private async Task PublishClanMessageAsync((ulong Guild, Guid Server) key, ulong activeSteamId, ClanChatLine line) + { + if (_disposed) + { + return; + } + + try + { + var evt = new ClanMessageReceivedEvent( + key.Guild, key.Server, line.SteamId, line.Name, line.Message, line.SteamId == activeSteamId); + // Supervisor-wide shutdown token, not a per-connection ct: an inbound line should publish + // regardless of one connection's reconnect cycle. + await eventBus.PublishAsync(evt, _shutdown.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a publish failure must not crash the socket callback. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPublishClanMessageFailed(logger, ex, key.Server); + } + } + + private async Task PublishClanStateAsync((ulong Guild, Guid Server) key, ClanProbeResult probe) + { + if (_disposed) + { + return; + } + + try + { + var evt = new ClanStateChangedEvent(key.Guild, key.Server, probe.Status, probe.Snapshot); + await eventBus.PublishAsync(evt, _shutdown.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a publish failure must not crash the socket callback. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPublishClanStateFailed(logger, ex, key.Server); + } + } +``` + +And the three log methods next to the existing `[LoggerMessage]` block: + +```csharp + [LoggerMessage(Level = LogLevel.Error, Message = "Relaying a clan message to server {ServerId} failed.")] + private static partial void LogClanSendFailed(ILogger logger, Exception exception, Guid serverId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Publishing a clan message for server {ServerId} failed.")] + private static partial void LogPublishClanMessageFailed(ILogger logger, Exception exception, Guid serverId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Publishing clan state for server {ServerId} failed.")] + private static partial void LogPublishClanStateFailed(ILogger logger, Exception exception, Guid serverId); +``` + +- [ ] **Step 6: Register the sender** + +In `ConnectionServiceCollectionExtensions.cs`, next to the existing `ITeamChatSender` registration, expose the same singleton instance under the new interface: + +```csharp + services.AddSingleton(sp => sp.GetRequiredService()); +``` + +Match the exact style already used for `ITeamChatSender` in that file — if it registers via `sp.GetRequiredService()`, mirror it verbatim so both interfaces resolve to one supervisor. + +- [ ] **Step 7: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1` +Expected: all pass, 7 more than after Task 2. + +- [ ] **Step 8: Commit** + +```bash +git add src/RustPlusBot.Features.Connections tests/RustPlusBot.Features.Connections.Tests +git commit -m "$(cat <<'EOF' +Publish clan chat and clan state from the supervisor + +Subscribe to the socket's clan events for the lifetime of a connected window, +probe the clan once on connect so state survives a restart, and publish both +onto the event bus. The supervisor also implements IClanChatSender explicitly, +since it differs from ITeamChatSender only by return type. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 4: Clan persistence (Domain, Persistence) + +**Files:** +- Create: `src/RustPlusBot.Domain/Clans/ClanState.cs` +- Create: `src/RustPlusBot.Domain/Clans/ClanPlayerName.cs` +- Create: `src/RustPlusBot.Persistence/Configurations/ClanStateConfiguration.cs` +- Create: `src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.cs` +- Create: `src/RustPlusBot.Persistence/Clans/IClanStore.cs` +- Create: `src/RustPlusBot.Persistence/Clans/ClanStore.cs` +- Create: `src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs` +- Modify: `src/RustPlusBot.Persistence/BotDbContext.cs` +- Modify: `src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs` +- Test: `tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs` + +**Interfaces:** +- Consumes: `ClanSnapshot` (Task 1). +- Produces: + +```csharp +public interface IClanStore +{ + Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + Task SaveAsync(ulong guildId, Guid serverId, ClanSnapshot snapshot, CancellationToken cancellationToken = default); + Task ClearAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + Task HasClanAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + Task> GetNamesAsync(ulong guildId, Guid serverId, IReadOnlyCollection steamIds, CancellationToken cancellationToken = default); + Task RecordNameAsync(ulong guildId, Guid serverId, ulong steamId, string name, CancellationToken cancellationToken = default); +} +``` + +`ClearAsync` returns true when a row was actually removed, so the caller can tell a real departure from a redundant teardown and avoid a pointless reconcile. + +- [ ] **Step 1: Write the failing tests** + +`tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs`. Follow the existing store tests in that project for how they build an in-memory/SQLite `BotDbContext` and seed a `RustServer` — reuse that helper verbatim. + +```csharp +[Fact] public async Task Returns_null_when_no_clan_is_stored() +[Fact] public async Task Round_trips_a_snapshot_including_members_roles_and_invites() +// Save a snapshot with 2 roles, 3 members (one with Notes, one Online), 1 invite; read it back +// and assert every scalar and every collection element matches, including LogoHash and Score. +[Fact] public async Task Overwrites_the_previous_snapshot_on_save() +[Fact] public async Task HasClan_is_false_before_save_and_true_after() +[Fact] public async Task Clear_removes_the_row_and_reports_true_only_the_first_time() +// First ClearAsync => true; second => false. +[Fact] public async Task Deleting_the_server_cascades_to_the_clan_state() +[Fact] public async Task Records_and_reads_back_player_names() +[Fact] public async Task Name_lookup_returns_only_the_requested_ids() +[Fact] public async Task Recording_a_name_twice_updates_rather_than_duplicating() +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dtk dotnet test tests/RustPlusBot.Persistence.Tests -maxcpucount:1 --filter FullyQualifiedName~ClanStoreTests` +Expected: FAIL — `IClanStore` does not exist. + +- [ ] **Step 3: Create the entities** + +`src/RustPlusBot.Domain/Clans/ClanState.cs`: + +```csharp +namespace RustPlusBot.Domain.Clans; + +/// +/// The latest known clan snapshot for one (guild, server). Row presence is the single source of +/// truth for whether the paired player is in a clan. +/// +public sealed class ClanState +{ + /// The owning guild snowflake. + public ulong GuildId { get; set; } + + /// The server id (FK to RustServer; primary key, one row per server). + public Guid ServerId { get; set; } + + /// The in-game clan identifier. + public long ClanId { get; set; } + + /// The clan's display name. + public string Name { get; set; } = string.Empty; + + /// When the clan was created (UTC). + public DateTimeOffset Created { get; set; } + + /// Steam64 id of the clan creator. + public ulong Creator { get; set; } + + /// The message of the day, or null when unset. + public string? Motd { get; set; } + + /// When the MOTD was last changed (UTC), or null. + public DateTimeOffset? MotdTimestamp { get; set; } + + /// Steam64 id of the player who last changed the MOTD, or null. + public ulong? MotdAuthor { get; set; } + + /// Stable hash of the clan logo, or null when no logo is set. + public string? LogoHash { get; set; } + + /// The clan colour as a packed ARGB integer, or null. + public int? Color { get; set; } + + /// The maximum member count, or null when uncapped. + public int? MaxMemberCount { get; set; } + + /// The clan score, or null when the server does not report one. + public long? Score { get; set; } + + /// The clan's roles, serialized as JSON. + public string RolesJson { get; set; } = "[]"; + + /// The clan's members, serialized as JSON. + public string MembersJson { get; set; } = "[]"; + + /// The clan's pending invites, serialized as JSON. + public string InvitesJson { get; set; } = "[]"; + + /// When this snapshot was last confirmed (UTC). + public DateTimeOffset LastSeenUtc { get; set; } +} +``` + +`src/RustPlusBot.Domain/Clans/ClanPlayerName.cs`: + +```csharp +namespace RustPlusBot.Domain.Clans; + +/// +/// A cached Steam64 id to display-name mapping. The clan API reports members by id only, so names +/// are harvested from clan chat and team snapshots, which do carry them. +/// +public sealed class ClanPlayerName +{ + /// The owning guild snowflake. + public ulong GuildId { get; set; } + + /// The server id (FK to RustServer). + public Guid ServerId { get; set; } + + /// The Steam64 id of the player. + public ulong SteamId { get; set; } + + /// The most recently observed display name. + public string Name { get; set; } = string.Empty; + + /// When the name was last observed (UTC). + public DateTimeOffset UpdatedUtc { get; set; } +} +``` + +- [ ] **Step 4: Create the EF configurations** + +`src/RustPlusBot.Persistence/Configurations/ClanStateConfiguration.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RustPlusBot.Domain.Clans; +using RustPlusBot.Domain.Servers; + +namespace RustPlusBot.Persistence.Configurations; + +internal sealed class ClanStateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.HasKey(s => s.ServerId); + builder.Property(s => s.Name).HasMaxLength(128); + builder.Property(s => s.Motd).HasMaxLength(1024); + builder.Property(s => s.LogoHash).HasMaxLength(64); + + // Removing a RustServer cascades to its clan state so no orphaned snapshot lingers. + builder.HasOne() + .WithOne() + .HasForeignKey(s => s.ServerId) + .OnDelete(DeleteBehavior.Cascade); + } +} +``` + +`src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RustPlusBot.Domain.Clans; +using RustPlusBot.Domain.Servers; + +namespace RustPlusBot.Persistence.Configurations; + +internal sealed class ClanPlayerNameConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.HasKey(n => new { n.ServerId, n.SteamId }); + builder.Property(n => n.Name).HasMaxLength(64); + + builder.HasOne() + .WithMany() + .HasForeignKey(n => n.ServerId) + .OnDelete(DeleteBehavior.Cascade); + } +} +``` + +- [ ] **Step 5: Register both in `BotDbContext`** + +Add the `DbSet` properties alongside the existing ones: + +```csharp + /// The latest known clan snapshot per server. + public DbSet ClanStates => Set(); + + /// Cached Steam id to display-name mappings for clan members. + public DbSet ClanPlayerNames => Set(); +``` + +and apply both configurations in `OnModelCreating`, following the existing lines exactly: + +```csharp + modelBuilder.ApplyConfiguration(new ClanStateConfiguration()); + modelBuilder.ApplyConfiguration(new ClanPlayerNameConfiguration()); +``` + +- [ ] **Step 6: Create the serializer** + +`src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs`: + +```csharp +using System.Text.Json; +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Persistence.Clans; + +/// +/// Serializes the clan's collections to and from the JSON columns on ClanState. The +/// collections are only ever read and written whole (diff, then render) and nothing queries +/// across them, so three normalised tables would add migration burden for no query benefit. +/// +internal static class ClanSnapshotSerializer +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + /// Serializes a collection to its JSON column value. + /// The element type. + /// The items to serialize. + /// The JSON text. + public static string Serialize(IReadOnlyList items) => JsonSerializer.Serialize(items, Options); + + /// Deserializes a JSON column value, yielding an empty list when the text is unusable. + /// The element type. + /// The stored JSON text. + /// The deserialized items, or an empty list. + public static IReadOnlyList Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json, Options) ?? []; + } + catch (JsonException) + { + // A corrupted column must not crash the bot; an empty list re-reads as a full change set. + return []; + } + } +} +``` + +- [ ] **Step 7: Create `IClanStore` and `ClanStore`** + +`src/RustPlusBot.Persistence/Clans/IClanStore.cs` — the interface exactly as given in the Interfaces block above, with full `///` docs on every member and parameter. + +`src/RustPlusBot.Persistence/Clans/ClanStore.cs` — a scoped `internal sealed class ClanStore(BotDbContext db) : IClanStore` that: +- `GetAsync` — `db.ClanStates.AsNoTracking().FirstOrDefaultAsync(s => s.ServerId == serverId && s.GuildId == guildId, ct)`, then rebuilds a `ClanSnapshot` using `ClanSnapshotSerializer.Deserialize(row.RolesJson)` and the member/invite equivalents. +- `SaveAsync` — finds the tracked row or creates one, assigns every scalar plus the three JSON columns and `LastSeenUtc`, then `SaveChangesAsync`. +- `ClearAsync` — finds the row; returns false when absent; otherwise removes it, saves, and returns true. +- `HasClanAsync` — `db.ClanStates.AnyAsync(...)`. +- `GetNamesAsync` — `db.ClanPlayerNames.AsNoTracking().Where(n => n.ServerId == serverId && steamIds.Contains(n.SteamId)).ToDictionaryAsync(n => n.SteamId, n => n.Name, ct)`. Return an empty dictionary when `steamIds` is empty, without querying. +- `RecordNameAsync` — upsert by `(ServerId, SteamId)`; skip the write when the stored name already equals the new one (ordinal), so an unchanged name does not churn the DB on every chat line. + +Every awaited call needs `.ConfigureAwait(false)`. Every public member needs `///` docs — put them on the interface and use `/// ` on the implementation. + +- [ ] **Step 8: Register the store** + +In `PersistenceServiceCollectionExtensions.cs`, after `services.AddScoped();`: + +```csharp + services.AddScoped(); +``` + +- [ ] **Step 9: Run the tests** + +Run: `dotnet test tests/RustPlusBot.Persistence.Tests -maxcpucount:1` +Expected: all pass, 9 more than before. + +- [ ] **Step 10: Create the migration** + +```bash +dotnet build RustPlusBot.slnx -maxcpucount:1 +dotnet ef migrations add ClanSupport \ + --project src/RustPlusBot.Persistence \ + --startup-project src/RustPlusBot.Host +``` + +Expected: a new `Migrations/_ClanSupport.cs` plus an updated `BotDbContextModelSnapshot.cs`. Open the migration and confirm it creates exactly two tables and no others — if it contains unrelated changes, the model snapshot was stale; investigate before continuing. + +Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `0 Error(s)`. + +- [ ] **Step 11: Commit** + +```bash +git add src/RustPlusBot.Domain/Clans src/RustPlusBot.Persistence tests/RustPlusBot.Persistence.Tests +git commit -m "$(cat <<'EOF' +Persist clan state and cached player names + +Add the ClanState and ClanPlayerName entities, their configurations, the +scoped IClanStore, and the ClanSupport migration. Members, roles and invites +are stored as JSON because they are only ever read and written whole. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 5: Workspace capability seam, pinning, and clan channel specs + +The reconciler currently provisions every declared channel unconditionally. This task adds a generic capability gate — reusable beyond clans — plus message pinning, then declares the clan channels and the clan-chat locator. + +**Files:** +- Create: `src/RustPlusBot.Features.Workspace/Registry/IWorkspaceCapabilityProvider.cs` +- Create: `src/RustPlusBot.Features.Workspace/Locating/IClanChatChannelLocator.cs` +- Create: `src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Registry/IWorkspaceRegistry.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs` +- Modify: `src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs` +- Modify: `src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs` +- Test: `tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs` +- Modify: the Workspace tests' fake gateway (find it under `tests/RustPlusBot.Features.Workspace.Tests/` — it implements `IWorkspaceGateway`) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `internal interface IWorkspaceCapabilityProvider { string Capability { get; } ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken); }` + - `ChannelSpec` gains a trailing `string? Capability = null`. + - `MessageSpec` gains a trailing `bool Pinned = false`. + - `IWorkspaceRegistry` gains `ValueTask IsCapabilityAvailableAsync(string capability, ulong guildId, Guid? serverId, CancellationToken cancellationToken)`. + - `IWorkspaceGateway` gains `Task PinMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken)`. + - `WorkspaceChannelKeys.ServerClanChat = "clanchat"`, `WorkspaceChannelKeys.ServerClanInfo = "claninfo"`. + - `WorkspaceMessageKeys.ClanOverview = "clan.overview"`, `ClanRoster = "clan.roster"`, `ClanInvites = "clan.invites"`. + - `WorkspaceCapabilities.Clan = "clan"`. + - `public interface IClanChatChannelLocator` with the same two members as `ITeamChatChannelLocator`. + +- [ ] **Step 1: Write the failing tests** + +`tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs`. Read the existing reconciler tests in that project first and reuse their fake gateway / in-memory store harness. + +```csharp +[Fact] public async Task Creates_a_capability_gated_channel_when_the_capability_is_available() +// Registry has a spec with Capability = "clan"; a provider returns true. +// Assert the gateway created a channel for that key and the store has a row. + +[Fact] public async Task Skips_and_deletes_a_capability_gated_channel_when_unavailable() +// Provider returns false and a ProvisionedChannel row already exists. +// Assert gateway.DeleteChannelAsync was called with that channel id AND the store row is gone. + +[Fact] public async Task Does_not_create_a_capability_gated_channel_that_was_never_provisioned() +// Provider false, no existing row => no create, no delete, no throw. + +[Fact] public async Task Treats_a_capability_with_no_registered_provider_as_unavailable() +// Spec has Capability = "ghost"; no provider registered. +// Assert the channel is not created. + +[Fact] public async Task Leaves_ungated_channels_untouched() +// A spec with Capability == null is created exactly as before. + +[Fact] public async Task Pins_a_pinned_message_only_when_it_is_newly_posted() +// First reconcile: PinMessageAsync called once for the pinned spec. +// Second reconcile (message still live, so it is edited): PinMessageAsync NOT called again. + +[Fact] public async Task Does_not_pin_messages_that_are_not_marked_pinned() + +[Fact] public async Task A_failed_pin_does_not_fail_the_reconcile() +// Fake gateway throws from PinMessageAsync; assert the message is still saved to the store. +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Workspace.Tests -maxcpucount:1 --filter FullyQualifiedName~CapabilityGatedChannelTests` +Expected: FAIL — `IWorkspaceCapabilityProvider` does not exist. + +- [ ] **Step 3: Add the capability seam** + +`src/RustPlusBot.Features.Workspace/Registry/IWorkspaceCapabilityProvider.cs`: + +```csharp +namespace RustPlusBot.Features.Workspace.Registry; + +/// +/// Answers whether an optional workspace capability currently applies to a scope. A +/// naming a capability is provisioned only while its provider reports +/// available, and its channel is deleted when it does not. +/// +internal interface IWorkspaceCapabilityProvider +{ + /// The capability name this provider answers for (matches ). + string Capability { get; } + + /// Reports whether the capability currently applies to the given scope. + /// The guild snowflake. + /// The server id, or null for the global scope. + /// A cancellation token. + /// True when the capability's channels should exist. + ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken); +} +``` + +Add the capability-name constants to `WorkspaceKeys.cs`: + +```csharp +/// Stable capability names gating optional workspace channels. +internal static class WorkspaceCapabilities +{ + /// Gates the per-server clan channels; available only while the paired player is in a clan. + public const string Clan = "clan"; +} +``` + +- [ ] **Step 4: Extend the two spec records** + +`ChannelSpec.cs` — append the parameter and its doc: + +```csharp +/// Optional capability gating this channel; null means always provisioned. +internal sealed record ChannelSpec( + WorkspaceScope Scope, + string Key, + string NameKey, + ChannelPermissionProfile Permissions, + int Order, + string? Capability = null); +``` + +`MessageSpec.cs`: + +```csharp +/// True to pin the message when it is first posted, so a channel that also +/// carries transient messages keeps this one reachable from the pin bar. +internal sealed record MessageSpec(WorkspaceScope Scope, string Key, string ChannelKey, bool Pinned = false); +``` + +- [ ] **Step 5: Aggregate providers in the registry** + +`IWorkspaceRegistry` gains: + +```csharp + /// + /// Reports whether a named capability currently applies. An unknown capability — one with no + /// registered provider — is unavailable, so a feature the host did not compose leaves no + /// orphaned channels behind. + /// + /// The capability name. + /// The guild snowflake. + /// The server id, or null for the global scope. + /// A cancellation token. + /// True when the capability's channels should exist. + ValueTask IsCapabilityAvailableAsync(string capability, + ulong guildId, + Guid? serverId, + CancellationToken cancellationToken); +``` + +`WorkspaceRegistry` — take the providers and index them: + +```csharp +internal sealed class WorkspaceRegistry( + IEnumerable channelProviders, + IEnumerable messageProviders, + IEnumerable capabilityProviders) : IWorkspaceRegistry +{ + private readonly List _channels = [.. channelProviders.SelectMany(p => p.GetChannelSpecs())]; + private readonly List _messages = [.. messageProviders.SelectMany(p => p.GetMessageSpecs())]; + + private readonly Dictionary _capabilities = + capabilityProviders.ToDictionary(p => p.Capability, StringComparer.Ordinal); + + /// + public ValueTask IsCapabilityAvailableAsync(string capability, + ulong guildId, + Guid? serverId, + CancellationToken cancellationToken) => + _capabilities.TryGetValue(capability, out var provider) + ? provider.IsAvailableAsync(guildId, serverId, cancellationToken) + : ValueTask.FromResult(false); + + // ... existing GetChannelSpecs / GetMessageSpecs unchanged +} +``` + +`WorkspaceRegistry` is registered as a singleton but `ClanCapabilityProvider` needs a scoped store, so the provider resolves its store per call from `IServiceScopeFactory` (Task 10). Do not change the registry's lifetime. + +- [ ] **Step 6: Gate creation and delete on unavailability in the reconciler** + +In `EnsureChannelsAsync`, replace the body of the `foreach (var spec in specs)` loop's opening with a gate, and track which specs were gated off: + +```csharp + var result = new Dictionary(StringComparer.Ordinal); + var specs = backends.Registry.GetChannelSpecs(scope); + var gatedOff = new List(); + + foreach (var spec in specs) + { + if (spec.Capability is { } capability && + !await backends.Registry + .IsCapabilityAvailableAsync(capability, guildId, serverId, cancellationToken) + .ConfigureAwait(false)) + { + gatedOff.Add(spec.Key); + continue; + } + + var name = localizer.Get(spec.NameKey, culture); + // ... existing body unchanged + } +``` + +Then, after the loop and before the orphan-retention logging, delete the gated-off channels: + +```csharp + // A capability that has gone away is an explicit removal, distinct from a spec merely + // disappearing from the registry (which is retained, below). + foreach (var key in gatedOff) + { + if (!existing.TryGetValue(key, out var stale)) + { + continue; + } + + await backends.Gateway.DeleteChannelAsync(guildId, stale.DiscordChannelId, cancellationToken) + .ConfigureAwait(false); + await backends.Store.DeleteChannelAsync(guildId, serverId, key, cancellationToken).ConfigureAwait(false); + logger.LogInformation( + "Removed channel '{Key}' for guild {GuildId}: its capability is no longer available.", key, guildId); + } +``` + +Finally, exclude the gated-off keys from the orphan-retention log so a removed channel is not also reported as retained: + +```csharp + var registryKeys = specs.Select(s => s.Key).ToHashSet(StringComparer.Ordinal); +``` + +is already correct — gated-off keys are still in `specs`, so they are not treated as orphans. No change needed there. + +- [ ] **Step 7: Add `DeleteChannelAsync` to `IWorkspaceStore`** + +`IWorkspaceStore` has `DeleteScopeAsync` but no single-channel delete. Add to `src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs`: + +```csharp + /// Deletes one provisioned channel row and any messages anchored in it. + /// The Discord guild snowflake. + /// The Rust server id, or null for the global scope. + /// The stable channel key to delete. + /// A cancellation token. + Task DeleteChannelAsync(ulong guildId, + Guid? serverId, + string channelKey, + CancellationToken cancellationToken = default); +``` + +Implement it in `WorkspaceStore`: remove the matching `ProvisionedChannel`, then remove every `ProvisionedMessage` for the scope whose `DiscordChannelId` equals the removed channel's id, then `SaveChangesAsync`. Deleting the messages matters — a stale `ProvisionedMessage` pointing at a deleted channel would make a later reconcile try to edit a message in a channel that no longer exists. + +Add a persistence test for it in the existing workspace store test file: + +```csharp +[Fact] public async Task Deleting_a_channel_also_removes_its_anchored_messages() +``` + +- [ ] **Step 8: Add pinning to the gateway** + +`IWorkspaceGateway`: + +```csharp + /// Pins a message. A no-op if it is already pinned or already gone. + /// The snowflake ID of the guild. + /// The snowflake ID of the channel containing the message. + /// The snowflake ID of the message to pin. + /// Token to cancel the operation. + Task PinMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken); +``` + +`DiscordWorkspaceGateway` — implement it following the file's existing channel/message resolution helpers: + +```csharp + /// + public async Task PinMessageAsync(ulong guildId, + ulong channelId, + ulong messageId, + CancellationToken cancellationToken) + { + if (client.GetGuild(guildId)?.GetTextChannel(channelId) is not { } channel) + { + return; + } + + if (await channel.GetMessageAsync(messageId).ConfigureAwait(false) is IUserMessage message) + { + await message.PinAsync().ConfigureAwait(false); + } + } +``` + +Match the surrounding code's actual client-access idiom rather than copying this verbatim if the file resolves channels differently. + +- [ ] **Step 9: Pin newly posted messages in `EnsureMessagesAsync`** + +In the post/edit block, capture whether the message was newly posted and pin it: + +```csharp + ulong messageId; + var newlyPosted = false; + if (item.LiveId is { } liveId) + { + await backends.Gateway + .EditMessageAsync(guildId, channelId, liveId, item.Payload, cancellationToken) + .ConfigureAwait(false); + messageId = liveId; + } + else + { + messageId = await backends.Gateway + .PostMessageAsync(guildId, channelId, item.Payload, cancellationToken) + .ConfigureAwait(false); + newlyPosted = true; + } + + // Pin only on first post: pinning is idempotent but costs an API call, and a + // re-posted message (declaration-order repair) also lands here as newly posted. + if (newlyPosted && item.Spec.Pinned) + { + try + { + await backends.Gateway.PinMessageAsync(guildId, channelId, messageId, cancellationToken) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // Broad catch: an unpinned embed is still correct; never fail a reconcile over it. + catch (Exception ex) +#pragma warning restore CA1031 + { + logger.LogWarning(ex, "Pinning message '{Key}' in guild {GuildId} failed.", item.Spec.Key, + guildId); + } + } +``` + +- [ ] **Step 10: Declare the clan keys, specs, and locator** + +`WorkspaceKeys.cs` — add to `WorkspaceChannelKeys`: + +```csharp + /// Key for the per-server #clanchat channel (provisioned only while a clan exists). + public const string ServerClanChat = "clanchat"; + + /// Key for the per-server #claninfo channel (provisioned only while a clan exists). + public const string ServerClanInfo = "claninfo"; +``` + +and to `WorkspaceMessageKeys`: + +```csharp + /// Key for the pinned clan overview embed. Rendered by Features.Clans. + public const string ClanOverview = "clan.overview"; + + /// Key for the pinned clan roster embed. Rendered by Features.Clans. + public const string ClanRoster = "clan.roster"; + + /// Key for the pinned clan invites embed. Rendered by Features.Clans. + public const string ClanInvites = "clan.invites"; +``` + +`ServerWorkspaceSpecProvider.cs` — insert the two channels after teamchat and renumber the rest: + +```csharp + public IEnumerable GetChannelSpecs() => + [ + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerInfo, "channel.info.name", + ChannelPermissionProfile.ReadOnly, 0), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerTeamChat, "channel.teamchat.name", + ChannelPermissionProfile.Interactive, 1), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerClanChat, "channel.clanchat.name", + ChannelPermissionProfile.Interactive, 2, WorkspaceCapabilities.Clan), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerClanInfo, "channel.claninfo.name", + ChannelPermissionProfile.ReadOnly, 3, WorkspaceCapabilities.Clan), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerEvents, "channel.events.name", + ChannelPermissionProfile.ReadOnly, 4), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerMap, "channel.map.name", + ChannelPermissionProfile.ReadOnly, 5), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerSwitches, "channel.switches.name", + ChannelPermissionProfile.Interactive, 6), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerAlarms, "channel.alarms.name", + ChannelPermissionProfile.Interactive, 7), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerStorageMonitors, "channel.storagemonitors.name", + ChannelPermissionProfile.Interactive, 8), + ]; +``` + +and add the three pinned messages to `GetMessageSpecs()`, after the existing entries: + +```csharp + // #claninfo also carries a transient change feed, so the anchored embeds are pinned to stay + // reachable once the feed pushes them up. + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanOverview, WorkspaceChannelKeys.ServerClanInfo, true), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanRoster, WorkspaceChannelKeys.ServerClanInfo, true), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanInvites, WorkspaceChannelKeys.ServerClanInfo, true), +``` + +`IClanChatChannelLocator.cs` — copy `ITeamChatChannelLocator` verbatim, renaming the type and swapping "#teamchat" for "#clanchat" in the docs. + +`ClanChatChannelLocator.cs` — copy `TeamChatChannelLocator` verbatim, renaming the type, the base-constructor key to `WorkspaceChannelKeys.ServerClanChat`, and the implemented interface. + +Register in `WorkspaceServiceCollectionExtensions.cs` next to the other locators: + +```csharp + services.AddSingleton(); +``` + +- [ ] **Step 11: Refresh the clan embeds too** + +`ServerInfoRefresher.cs` — append the three keys to the `Keys` array: + +```csharp + private static readonly string[] Keys = + [ + WorkspaceMessageKeys.ServerInfo, + WorkspaceMessageKeys.ServerEvents, + WorkspaceMessageKeys.ServerTeam, + WorkspaceMessageKeys.ClanOverview, + WorkspaceMessageKeys.ClanRoster, + WorkspaceMessageKeys.ClanInvites, + ]; +``` + +The existing `if (!_renderers.TryGetValue(key, out var renderer)) continue;` already makes these no-ops when `Features.Clans` is not composed, and the existing missing-message path already falls back to a full reconcile. + +- [ ] **Step 12: Update the fake gateway and run the suite** + +Add `PinMessageAsync` to the Workspace tests' fake gateway, recording `(channelId, messageId)` calls in a public list so the pin tests can assert on it, plus a settable `bool ThrowOnPin` for the failure test. + +Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `0 Error(s)`. + +Run: `dotnet test tests/RustPlusBot.Features.Workspace.Tests tests/RustPlusBot.Persistence.Tests -maxcpucount:1` +Expected: all pass, 9 more than before (8 capability/pin + 1 store). + +- [ ] **Step 13: Commit** + +```bash +git add src/RustPlusBot.Features.Workspace src/RustPlusBot.Persistence \ + tests/RustPlusBot.Features.Workspace.Tests tests/RustPlusBot.Persistence.Tests +git commit -m "$(cat <<'EOF' +Gate workspace channels on capabilities and support pinning + +Add a generic IWorkspaceCapabilityProvider seam so a ChannelSpec can name a +capability: the reconciler provisions it only while available and deletes the +channel and its anchored messages when it is not. A capability with no +registered provider counts as unavailable. Also add MessageSpec.Pinned, pinned +on first post only, and declare the two clan channels and three clan embeds. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 6: Share the dedup buffer across both bridges + +`RelayDedupBuffer` is `internal` to `Features.Chat`, but the clan bridge needs the same instance — and it must not let a clan echo cancel an identical team line. Move it to `Abstractions` and key it by channel kind. This is a prerequisite for Task 7, so it lands on its own. + +**Files:** +- Create: `src/RustPlusBot.Abstractions/Chat/RelayDedupBuffer.cs` +- Create: `src/RustPlusBot.Abstractions/Chat/ChatChannelKind.cs` +- Delete: `src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs` +- Modify: `src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs` +- Modify: `src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs` +- Modify: `src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs` +- Modify: `tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs` +- Modify: `tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs`, `TeamChatInboundProcessorTests.cs`, `ChatRegistrationTests.cs` + +**Interfaces:** +- Consumes: `IClock` (existing). +- Produces: `public enum ChatChannelKind { Team = 0, Clan = 1 }` and `public sealed class RelayDedupBuffer(IClock clock)` with `void Record(ChatChannelKind kind, (ulong Guild, Guid Server) key, string text)` and `bool TryConsume(ChatChannelKind kind, (ulong Guild, Guid Server) key, string text)`, both in namespace `RustPlusBot.Abstractions.Chat`. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs` (keeping every existing test, updated for the new signature): + +```csharp + [Fact] + public void A_clan_echo_does_not_consume_an_identical_team_entry() + { + var clock = new FakeClock(DateTimeOffset.UnixEpoch); + var buffer = new RelayDedupBuffer(clock); + var key = (1UL, Guid.NewGuid()); + + buffer.Record(ChatChannelKind.Team, key, "[dave] hello"); + + Assert.False(buffer.TryConsume(ChatChannelKind.Clan, key, "[dave] hello")); + Assert.True(buffer.TryConsume(ChatChannelKind.Team, key, "[dave] hello")); + } + + [Fact] + public void Consumes_a_clan_entry_recorded_for_the_same_key() + { + var clock = new FakeClock(DateTimeOffset.UnixEpoch); + var buffer = new RelayDedupBuffer(clock); + var key = (1UL, Guid.NewGuid()); + + buffer.Record(ChatChannelKind.Clan, key, "[dave] hello"); + + Assert.True(buffer.TryConsume(ChatChannelKind.Clan, key, "[dave] hello")); + } +``` + +Use whatever fake clock the existing tests in this file already use — do not introduce a new one. + +- [ ] **Step 2: Run to verify failure** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Chat.Tests -maxcpucount:1` +Expected: FAIL — compile error, `Record` takes two arguments. + +- [ ] **Step 3: Move and re-key the buffer** + +`src/RustPlusBot.Abstractions/Chat/ChatChannelKind.cs`: + +```csharp +namespace RustPlusBot.Abstractions.Chat; + +/// Which in-game chat channel a relayed line belongs to. +public enum ChatChannelKind +{ + /// In-game team chat. + Team = 0, + + /// In-game clan chat. + Clan = 1, +} +``` + +`src/RustPlusBot.Abstractions/Chat/RelayDedupBuffer.cs` — the existing implementation moved verbatim, with three changes: +1. namespace becomes `RustPlusBot.Abstractions.Chat` and the class becomes `public sealed`; +2. the dictionary key becomes `(ChatChannelKind Kind, ulong Guild, Guid Server)`; +3. `Record` and `TryConsume` take `ChatChannelKind kind` as their first parameter and build the composite key internally. + +The class doc should now read: + +```csharp +/// +/// Short-lived record of lines the bridges relayed into the game, keyed by (channel kind, guild, +/// server). When the bot's active player echoes a relayed line back on the socket, +/// matches and removes one entry so the relay drops the echo instead of +/// re-posting it to Discord. The channel kind is part of the key so an identical line relayed to +/// both team and clan chat within the TTL cannot cross-cancel. +/// +/// Drives entry expiry. +``` + +Delete `src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs`. + +- [ ] **Step 4: Update the team bridge call sites** + +`TeamChatRelay.cs` — add `using RustPlusBot.Abstractions.Chat;` and change the guard to: + +```csharp + if (evt.FromActivePlayer && dedup.TryConsume(ChatChannelKind.Team, (evt.GuildId, evt.ServerId), evt.Message)) +``` + +`TeamChatInboundProcessor.cs` — add the using and change the record call to: + +```csharp + dedup.Record(ChatChannelKind.Team, key, text); +``` + +`ChatServiceCollectionExtensions.cs` — the `services.AddSingleton();` line stays, but its `using` moves from `RustPlusBot.Features.Chat.Relaying` to `RustPlusBot.Abstractions.Chat`. Keeping the registration in `AddChat()` is deliberate: `AddClans()` will register it with `TryAddSingleton` so the two features share one instance in either composition order. + +- [ ] **Step 5: Run the Chat suite** + +Run: `dotnet test tests/RustPlusBot.Features.Chat.Tests -maxcpucount:1` +Expected: all pass, 2 more than before. + +Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `0 Error(s)`. + +- [ ] **Step 6: Commit** + +```bash +git add src/RustPlusBot.Abstractions/Chat src/RustPlusBot.Features.Chat tests/RustPlusBot.Features.Chat.Tests +git commit -m "$(cat <<'EOF' +Share the relay dedup buffer and key it by channel kind + +Move RelayDedupBuffer to Abstractions so both chat bridges can share one +instance, and add the channel kind to its key so an identical line relayed to +team and clan chat within the TTL cannot cross-cancel. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 7: The clan chat bridge + +**Files:** +- Create: `src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj` +- Create: `src/RustPlusBot.Features.Clans/Webhooks/IClanChatWebhookPoster.cs` +- Create: `src/RustPlusBot.Features.Clans/Webhooks/DiscordClanChatWebhookPoster.cs` +- Create: `src/RustPlusBot.Features.Clans/Relaying/ClanChatRelay.cs` +- Create: `src/RustPlusBot.Features.Clans/Inbound/InboundMessage.cs` +- Create: `src/RustPlusBot.Features.Clans/Inbound/InboundOutcome.cs` +- Create: `src/RustPlusBot.Features.Clans/Inbound/ClanChatInboundProcessor.cs` +- Create: `tests/RustPlusBot.Features.Clans.Tests/RustPlusBot.Features.Clans.Tests.csproj` +- Create: `tests/RustPlusBot.Features.Clans.Tests/ClanChatRelayTests.cs` +- Create: `tests/RustPlusBot.Features.Clans.Tests/ClanChatInboundProcessorTests.cs` +- Modify: `RustPlusBot.slnx` + +**Interfaces:** +- Consumes: `ClanMessageReceivedEvent` (T1), `IClanChatSender`/`ClanChatSendResult` (T2), `IClanChatChannelLocator` (T5), `RelayDedupBuffer`/`ChatChannelKind` (T6), `IMuteStore` and `BotTeamChat.Prefix` (existing). +- Produces: `internal sealed class ClanChatRelay` with `Task RelayAsync(ClanMessageReceivedEvent evt, CancellationToken ct)`; `internal sealed class ClanChatInboundProcessor` with `Task ProcessAsync(InboundMessage message, CancellationToken ct)`; `public interface IClanChatWebhookPoster` with `Task PostAsync(ulong channelId, string username, string message, CancellationToken ct)`; `internal sealed record InboundMessage(bool AuthorIsBotOrWebhook, ulong ChannelId, string DisplayName, string Content)`; `internal enum InboundOutcome { Ignored, Sent, Failed }`. + +`InboundMessage`/`InboundOutcome` are duplicated rather than shared: they are trivial DTOs private to each bridge's Discord adapter, and hoisting them into Abstractions to save eight lines would couple two features that otherwise share nothing. + +- [ ] **Step 1: Scaffold the projects** + +Create `src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj` by copying `src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj` verbatim, then setting its `ProjectReference`s to: `RustPlusBot.Abstractions`, `RustPlusBot.Discord`, `RustPlusBot.Domain`, `RustPlusBot.Features.Connections`, `RustPlusBot.Features.Workspace`, `RustPlusBot.Localization`, `RustPlusBot.Persistence`. + +Create `tests/RustPlusBot.Features.Clans.Tests/RustPlusBot.Features.Clans.Tests.csproj` by copying `tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj` verbatim and pointing its single `ProjectReference` at `RustPlusBot.Features.Clans`. + +Add an `InternalsVisibleTo` for the test assembly following whatever the Chat project does (either an `AssemblyInfo.cs` or an MSBuild item). + +Add both projects to `RustPlusBot.slnx` under the `src/` and `tests/` folders, matching the existing entries' formatting exactly. + +Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `0 Error(s)` with two new (empty) projects. + +- [ ] **Step 2: Write the failing tests** + +`tests/RustPlusBot.Features.Clans.Tests/ClanChatRelayTests.cs` — model on `TeamChatRelayTests.cs`, including its private static `Build(...)` factory returning the SUT plus substitutes: + +```csharp +[Fact] public async Task Posts_a_normal_message_via_webhook() +[Fact] public async Task Drops_a_bot_prefixed_line_from_the_active_player() +// evt.FromActivePlayer = true, Message = "[R+] pop: 42" => poster never called. +[Fact] public async Task Keeps_a_bot_prefixed_line_from_another_player() +// FromActivePlayer = false => posted. Another player typing "[R+]" is not our echo. +[Fact] public async Task Drops_our_own_echo() +// dedup.Record(ChatChannelKind.Clan, key, text) first => poster never called. +[Fact] public async Task Does_not_consume_a_team_dedup_entry() +// dedup.Record(ChatChannelKind.Team, key, text) => the clan line IS posted. +[Fact] public async Task Drops_a_command_invocation() +// IMuteStore.GetPrefixAsync returns "!"; Message = "!pop" => poster never called. +[Fact] public async Task Ignores_leading_whitespace_when_matching_the_command_prefix() +[Fact] public async Task Does_nothing_when_the_channel_is_not_provisioned() +// locator returns null => poster never called, and the prefix is never queried. +``` + +`tests/RustPlusBot.Features.Clans.Tests/ClanChatInboundProcessorTests.cs` — model on `TeamChatInboundProcessorTests.cs`: + +```csharp +[Fact] public async Task Ignores_bot_and_webhook_authors() +[Fact] public async Task Ignores_empty_content() +[Fact] public async Task Ignores_a_channel_that_is_not_a_clanchat() +[Fact] public async Task Ignores_a_muted_server_without_recording_a_dedup_entry() +// Assert the sender was never called AND buffer.TryConsume(Clan, key, text) is false. +[Fact] public async Task Records_the_dedup_entry_before_sending() +// Use a sender substitute whose Returns callback asserts the entry is already recorded. +[Fact] public async Task Formats_the_line_with_the_display_name() +// Assert the sent text is exactly "[dave] hello". +[Fact] public async Task Reports_Failed_when_the_send_fails() +[Fact] public async Task Reports_Failed_when_there_is_no_live_socket() +// ClanChatSendResult.NotConnected => InboundOutcome.Failed, so the user sees the ❌ reaction. +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` +Expected: FAIL — compile errors, none of the types exist. + +- [ ] **Step 4: Implement the poster** + +`IClanChatWebhookPoster.cs` and `DiscordClanChatWebhookPoster.cs` — copy `ITeamChatWebhookPoster` / `DiscordTeamChatWebhookPoster` verbatim, renaming the types, changing `WebhookName` to `"RustPlusBot ClanChat"`, and updating the doc comments and the `[LoggerMessage]` text to say "clan chat line". + +- [ ] **Step 5: Implement the relay** + +`src/RustPlusBot.Features.Clans/Relaying/ClanChatRelay.cs` — structurally identical to `TeamChatRelay`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Chat; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Clans.Webhooks; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Commands; + +namespace RustPlusBot.Features.Clans.Relaying; + +/// +/// Relays one received in-game clan message into its Discord #clanchat channel, dropping our own +/// echoes and command invocations. +/// +/// Resolves the target #clanchat channel. +/// Posts the line via webhook. +/// Tracks lines the bridge relayed into the game so their echoes can be dropped. +/// Opens a scope to read the scoped command prefix. +internal sealed class ClanChatRelay( + IClanChatChannelLocator locator, + IClanChatWebhookPoster poster, + RelayDedupBuffer dedup, + IServiceScopeFactory scopeFactory) +{ + /// Handles one . + /// The received clan message. + /// A cancellation token. + /// A task that completes when the line has been relayed or dropped. + public async Task RelayAsync(ClanMessageReceivedEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + + if (evt.FromActivePlayer && evt.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) + { + return; // Bot-originated line echoing back; #clanchat carries only human discussion. + } + + if (evt.FromActivePlayer && + dedup.TryConsume(ChatChannelKind.Clan, (evt.GuildId, evt.ServerId), evt.Message)) + { + return; // Our own relayed line echoing back; do not re-post. + } + + // The locator is an in-memory cache, so resolve the channel first: unmapped servers exit + // before the per-message prefix query below. + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (channelId is null) + { + return; + } + + // A command invocation gets its reply in game; the bare trigger line is noise in Discord. + var prefix = await GetCommandPrefixAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(prefix) && + evt.Message.TrimStart().StartsWith(prefix, StringComparison.Ordinal)) + { + return; + } + + await poster.PostAsync(channelId.Value, evt.SenderName, evt.Message, cancellationToken).ConfigureAwait(false); + } + + private async Task GetCommandPrefixAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + // IMuteStore is scoped, so resolve it from a fresh scope rather than capturing it on this singleton. + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var muteStore = scope.ServiceProvider.GetRequiredService(); + return await muteStore.GetPrefixAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + } + } +} +``` + +`BotTeamChat.Prefix` is reused deliberately: it is the bot's single marker for its own in-game output, not a team-specific constant. + +- [ ] **Step 6: Implement the inbound path** + +`Inbound/InboundMessage.cs` and `Inbound/InboundOutcome.cs` — copy the Chat equivalents verbatim into the `RustPlusBot.Features.Clans.Inbound` namespace, updating "#teamchat" to "#clanchat" in the docs. + +`Inbound/ClanChatInboundProcessor.cs` — copy `TeamChatInboundProcessor` with these substitutions: `IClanChatChannelLocator`, `IClanChatSender`, `dedup.Record(ChatChannelKind.Clan, key, text)`, and + +```csharp + return result == ClanChatSendResult.Sent ? InboundOutcome.Sent : InboundOutcome.Failed; +``` + +- [ ] **Step 7: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` +Expected: PASS, 17 tests. **Confirm the assembly reports 17, not 0** — a build failure in a new project reports zero tests and looks green. + +- [ ] **Step 8: Commit** + +```bash +git add RustPlusBot.slnx src/RustPlusBot.Features.Clans tests/RustPlusBot.Features.Clans.Tests +git commit -m "$(cat <<'EOF' +Add the clan chat bridge + +Relay in-game clan chat into #clanchat via webhook impersonation and relay +Discord messages back with record-then-send dedup, mirroring the team chat +bridge including its bot-prefix, echo, mute and command-prefix gates. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 8: The clan snapshot differ + +A pure function, so this is the highest-value test target in the feature. No I/O, no DI. + +**Files:** +- Create: `src/RustPlusBot.Features.Clans/State/ClanChange.cs` +- Create: `src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs` +- Create: `tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs` + +**Interfaces:** +- Consumes: `ClanSnapshot` and friends (T1). +- Produces: + +```csharp +internal enum ClanChangeKind +{ + Dissolved, Renamed, MotdChanged, MemberJoined, MemberLeft, + MemberPromoted, MemberDemoted, InviteSent, InviteAccepted, InviteRevoked, + LogoChanged, ColorChanged, ScoreChanged, +} + +internal sealed record ClanChange( + ClanChangeKind Kind, + ulong? SteamId, + ulong? ActorSteamId, + string? Text, + string? RoleName, + long? Score); + +internal static class ClanSnapshotDiffer +{ + public static IReadOnlyList Diff(ClanSnapshot? previous, ClanSnapshot? current); +} +``` + +**Behaviour contract, fixed and deterministic:** + +- `previous is null && current is not null` — a **first** snapshot. Emit **nothing**. A newly detected clan must not spray one "joined" per existing member into a brand-new channel. +- `previous is not null && current is null` — emit exactly `[Dissolved]`. +- both null — empty. +- `previous.ClanId != current.ClanId` — the player joined a *different* clan. Treat as a first snapshot: emit nothing. +- Otherwise emit changes in this exact order: `Renamed`, `MotdChanged`, `MemberJoined`, `MemberLeft`, `MemberPromoted`/`MemberDemoted`, `InviteSent`, `InviteAccepted`, `InviteRevoked`, `LogoChanged`, `ColorChanged`, `ScoreChanged`. +- `InviteAccepted` wins over `MemberJoined` + `InviteRevoked` when a steam id leaves `Invites` and appears in `Members` in the same diff. That id must NOT also produce a `MemberJoined`. +- Promotion vs demotion is decided by comparing the `Rank` of the old and new roles, resolved from `current.Roles`. Lower rank = higher standing, so `newRank < oldRank` is a promotion. If either role id is unknown in `current.Roles`, emit neither — an unresolvable role change is not worth a wrong claim. +- String comparisons are `StringComparison.Ordinal`. + +- [ ] **Step 1: Write the failing tests** + +`tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs`. Build snapshots with a private static helper so each test states only what it varies: + +```csharp +private static ClanSnapshot Clan( + long clanId = 1, + string name = "Wolves", + string? motd = null, + ulong? motdAuthor = null, + string? logoHash = null, + int? color = null, + long? score = null, + IReadOnlyList? roles = null, + IReadOnlyList? members = null, + IReadOnlyList? invites = null) => ...; + +private static ClanRoleSnapshot Role(int roleId, int rank, string name) => + new(roleId, rank, name, false, false, false, false, false, false, false, false, false); + +private static ClanMemberSnapshot Member(ulong steamId, int roleId = 1, bool online = false) => + new(steamId, roleId, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, null, online); + +private static ClanInviteSnapshot Invite(ulong steamId, ulong recruiter = 99) => + new(steamId, recruiter, DateTimeOffset.UnixEpoch); +``` + +Tests: + +```csharp +[Fact] public void Emits_nothing_for_a_first_snapshot() +[Fact] public void Emits_nothing_when_both_snapshots_are_null() +[Fact] public void Emits_nothing_when_nothing_changed() +[Fact] public void Emits_dissolved_when_the_clan_goes_away() +[Fact] public void Emits_nothing_when_the_player_joined_a_different_clan() +// previous.ClanId = 1, current.ClanId = 2 with entirely different members => empty. +[Fact] public void Detects_a_rename() +[Fact] public void Detects_a_motd_change_and_carries_the_author() +[Fact] public void Detects_a_member_joining() +[Fact] public void Detects_a_member_leaving() +[Fact] public void Detects_a_promotion_using_rank_order() +// Roles: 1 => rank 2 "Member", 2 => rank 0 "Leader". Member moves 1 -> 2 => MemberPromoted, RoleName "Leader". +[Fact] public void Detects_a_demotion_using_rank_order() +[Fact] public void Emits_no_role_change_when_the_new_role_is_unknown() +[Fact] public void Detects_an_invite_being_sent() +[Fact] public void Detects_an_invite_being_revoked() +[Fact] public void Reports_an_accepted_invite_once_and_not_as_a_join() +// previous: invites [7], members []. current: invites [], members [7]. +// Assert exactly one change, Kind == InviteAccepted, SteamId == 7. +[Fact] public void Detects_a_logo_change() +[Fact] public void Detects_a_colour_change() +[Fact] public void Detects_a_score_change_and_carries_the_new_score() +[Fact] public void Orders_changes_deterministically() +// A snapshot pair that changes name, motd, members, invites and score at once; +// assert the Kind sequence equals the documented order exactly. +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1 --filter FullyQualifiedName~ClanSnapshotDifferTests` +Expected: FAIL — `ClanSnapshotDiffer` does not exist. + +- [ ] **Step 3: Implement `ClanChange`** + +`src/RustPlusBot.Features.Clans/State/ClanChange.cs`: + +```csharp +namespace RustPlusBot.Features.Clans.State; + +/// The kind of clan change detected between two snapshots. +internal enum ClanChangeKind +{ + /// The clan was dissolved, or the player left it. + Dissolved = 0, + + /// The clan was renamed. + Renamed = 1, + + /// The message of the day changed. + MotdChanged = 2, + + /// A member joined the clan. + MemberJoined = 3, + + /// A member is no longer in the clan. The API cannot distinguish leaving from being kicked. + MemberLeft = 4, + + /// A member moved to a higher-standing role. + MemberPromoted = 5, + + /// A member moved to a lower-standing role. + MemberDemoted = 6, + + /// A player was invited to the clan. + InviteSent = 7, + + /// An invited player joined, observed as a single invite-to-member transition. + InviteAccepted = 8, + + /// An invitation went away without the player joining. + InviteRevoked = 9, + + /// The clan logo changed. + LogoChanged = 10, + + /// The clan colour changed. + ColorChanged = 11, + + /// The clan score changed. + ScoreChanged = 12, +} + +/// One detected clan change, ready to be rendered into the #claninfo feed. +/// What changed. +/// The player the change is about, or null for clan-wide changes. +/// The player who caused the change (MOTD author, recruiter), or null. +/// Free text carried by the change (the new name or the new MOTD), or null. +/// The new role name for a promotion or demotion, or null. +/// The new score for a score change, or null. +internal sealed record ClanChange( + ClanChangeKind Kind, + ulong? SteamId = null, + ulong? ActorSteamId = null, + string? Text = null, + string? RoleName = null, + long? Score = null); +``` + +- [ ] **Step 4: Implement the differ** + +`src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs`: + +```csharp +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Features.Clans.State; + +/// +/// Compares two clan snapshots and produces the feed events between them. Pure: no I/O, no clock, +/// no state. Emission order is fixed so the feed reads consistently. +/// +internal static class ClanSnapshotDiffer +{ + /// Diffs two snapshots. + /// The last known snapshot, or null when none was stored. + /// The new snapshot, or null when the clan is gone. + /// The detected changes, in a fixed order. + public static IReadOnlyList Diff(ClanSnapshot? previous, ClanSnapshot? current) + { + if (previous is null) + { + // A first snapshot is a baseline, not news: announcing every existing member as a + // "joined" would spam a brand-new channel on first detection. + return []; + } + + if (current is null) + { + return [new ClanChange(ClanChangeKind.Dissolved, Text: previous.Name)]; + } + + if (previous.ClanId != current.ClanId) + { + // A different clan entirely; re-baseline rather than diffing unrelated rosters. + return []; + } + + var changes = new List(); + + if (!string.Equals(previous.Name, current.Name, StringComparison.Ordinal)) + { + changes.Add(new ClanChange(ClanChangeKind.Renamed, Text: current.Name)); + } + + if (!string.Equals(previous.Motd, current.Motd, StringComparison.Ordinal)) + { + changes.Add(new ClanChange(ClanChangeKind.MotdChanged, ActorSteamId: current.MotdAuthor, + Text: current.Motd)); + } + + var previousMembers = previous.Members.ToDictionary(m => m.SteamId); + var currentMembers = current.Members.ToDictionary(m => m.SteamId); + var previousInvites = previous.Invites.Select(i => i.SteamId).ToHashSet(); + var currentInvites = current.Invites.Select(i => i.SteamId).ToHashSet(); + + // An id that left Invites and appeared in Members in one step is an acceptance, reported + // once — never as a join plus a revocation. + var accepted = previousInvites + .Where(id => !currentInvites.Contains(id) && currentMembers.ContainsKey(id) && !previousMembers.ContainsKey(id)) + .ToHashSet(); + + foreach (var id in currentMembers.Keys.Where(id => !previousMembers.ContainsKey(id) && !accepted.Contains(id)) + .Order()) + { + changes.Add(new ClanChange(ClanChangeKind.MemberJoined, id)); + } + + foreach (var id in previousMembers.Keys.Where(id => !currentMembers.ContainsKey(id)).Order()) + { + changes.Add(new ClanChange(ClanChangeKind.MemberLeft, id)); + } + + var rolesById = current.Roles.ToDictionary(r => r.RoleId); + foreach (var (id, member) in currentMembers.OrderBy(kv => kv.Key)) + { + if (!previousMembers.TryGetValue(id, out var before) || before.RoleId == member.RoleId) + { + continue; + } + + if (!rolesById.TryGetValue(member.RoleId, out var newRole) || + !rolesById.TryGetValue(before.RoleId, out var oldRole)) + { + // An unresolvable role change is not worth guessing a direction over. + continue; + } + + // Lower Rank is higher standing. + var kind = newRole.Rank < oldRole.Rank ? ClanChangeKind.MemberPromoted : ClanChangeKind.MemberDemoted; + changes.Add(new ClanChange(kind, id, RoleName: newRole.Name)); + } + + foreach (var invite in current.Invites.Where(i => !previousInvites.Contains(i.SteamId)) + .OrderBy(i => i.SteamId)) + { + changes.Add(new ClanChange(ClanChangeKind.InviteSent, invite.SteamId, invite.Recruiter)); + } + + foreach (var id in accepted.Order()) + { + changes.Add(new ClanChange(ClanChangeKind.InviteAccepted, id)); + } + + foreach (var id in previousInvites + .Where(id => !currentInvites.Contains(id) && !accepted.Contains(id)) + .Order()) + { + changes.Add(new ClanChange(ClanChangeKind.InviteRevoked, id)); + } + + if (!string.Equals(previous.LogoHash, current.LogoHash, StringComparison.Ordinal)) + { + changes.Add(new ClanChange(ClanChangeKind.LogoChanged)); + } + + if (previous.Color != current.Color) + { + changes.Add(new ClanChange(ClanChangeKind.ColorChanged)); + } + + if (previous.Score != current.Score) + { + changes.Add(new ClanChange(ClanChangeKind.ScoreChanged, Score: current.Score)); + } + + return changes; + } +} +``` + +- [ ] **Step 5: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` +Expected: PASS, 36 tests total (17 from Task 7 + 19 here). + +- [ ] **Step 6: Commit** + +```bash +git add src/RustPlusBot.Features.Clans/State tests/RustPlusBot.Features.Clans.Tests/State +git commit -m "$(cat <<'EOF' +Add the clan snapshot differ + +Compare consecutive clan snapshots into an ordered, deterministic change list. +A first snapshot and a switch to a different clan both re-baseline silently +rather than announcing every existing member, and an invite-to-member +transition is reported once as an acceptance. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 9: Name resolution and the three clan embeds + +**Files:** +- Create: `src/RustPlusBot.Features.Clans/Names/IClanNameResolver.cs` +- Create: `src/RustPlusBot.Features.Clans/Names/ClanNameResolver.cs` +- Create: `src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs` +- Create: `src/RustPlusBot.Features.Clans/Messages/ClanRosterMessageRenderer.cs` +- Create: `src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs` +- Create: `src/RustPlusBot.Features.Clans/ClanComponentIds.cs` +- Create: `tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs` +- Create: `tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs` + +**Interfaces:** +- Consumes: `IClanStore` (T4), `ClanSnapshot` (T1), `IMessageRenderer`/`MessageRenderContext`/`MessagePayload` (existing), `ILocalizer` (existing). +- Produces: + - `internal interface IClanNameResolver { Task> ResolveAsync(ulong guildId, Guid serverId, IReadOnlyCollection steamIds, CancellationToken cancellationToken); }` + - `internal static class ClanComponentIds` with `public const string SetMotdButtonPrefix = "clan:motd:"` and `public const string SetMotdModalPrefix = "clan:motdmodal:"` and `public const string MotdInputId = "clan:motd:text"`. + - Three `public sealed class Clan*MessageRenderer : IMessageRenderer`, each with a `public const string Key` matching its `WorkspaceMessageKeys` value. + +Renderers are `public` because `IMessageRenderer` is public and they are resolved by the Workspace reconciler across an assembly boundary — the same reason `ServerTeamMessageRenderer` is public. + +**Name resolution contract:** +`ResolveAsync` returns a dictionary covering **every** requested id. Known ids map to the cached display name; unknown ids map to a Steam profile markdown link, `[{id}](https://steamcommunity.com/profiles/{id})`. Callers never have to null-check. + +- [ ] **Step 1: Write the failing tests** + +`tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs`: + +```csharp +[Fact] public async Task Returns_cached_names_for_known_ids() +[Fact] public async Task Falls_back_to_a_steam_profile_link_for_unknown_ids() +// Assert the value is exactly "[76561198000000000](https://steamcommunity.com/profiles/76561198000000000)". +[Fact] public async Task Covers_every_requested_id() +[Fact] public async Task Returns_an_empty_map_for_an_empty_request_without_querying_the_store() +``` + +`tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs`: + +```csharp +// Overview +[Fact] public async Task Overview_returns_an_empty_payload_without_a_server_id() +[Fact] public async Task Overview_returns_an_empty_payload_when_no_clan_is_stored() +[Fact] public async Task Overview_shows_the_clan_name_score_and_member_count() +[Fact] public async Task Overview_shows_the_motd_and_its_author() +[Fact] public async Task Overview_uses_the_clan_colour_when_set() +[Fact] public async Task Overview_shows_the_set_motd_button_when_the_active_player_may_set_it() +[Fact] public async Task Overview_hides_the_set_motd_button_when_the_active_player_may_not() +[Fact] public async Task Overview_hides_the_set_motd_button_when_the_active_player_is_not_a_member() + +// Roster +[Fact] public async Task Roster_groups_members_by_role_in_rank_order() +// Roles: rank 0 "Leader", rank 1 "Officer", rank 2 "Member". +// Assert the Leader group's text appears before the Officer group's, which precedes Member's. +[Fact] public async Task Roster_puts_online_members_first_within_a_role() +[Fact] public async Task Roster_marks_online_and_offline_members_distinctly() +[Fact] public async Task Roster_shows_notes_when_present() +[Fact] public async Task Roster_falls_back_to_a_profile_link_for_an_unknown_name() +[Fact] public async Task Roster_lists_members_whose_role_is_unknown_under_a_fallback_group() + +// Invites +[Fact] public async Task Invites_returns_an_empty_payload_when_there_are_none() +// Assert Text, Embed and Components are ALL null, so the reconciler skips posting. +[Fact] public async Task Invites_lists_the_invitee_and_the_recruiter() +``` + +Use a substituted `ILocalizer` that returns the key itself (`localizer.Get(Arg.Any(), Arg.Any()).Returns(ci => ci.ArgAt(0))` plus the params overload), so assertions match on keys rather than on English copy. That keeps these tests from breaking when wording changes. + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1 --filter FullyQualifiedName~Clan` +Expected: FAIL — the renderer types do not exist. + +- [ ] **Step 3: Implement the name resolver** + +`Names/IClanNameResolver.cs` — the interface above with full `///` docs. + +`Names/ClanNameResolver.cs`: + +```csharp +using System.Globalization; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.Names; + +/// +/// Resolves clan member Steam ids to display names. The clan API reports members by id only, so +/// names are harvested from clan chat and team snapshots; an id we have never seen a name for +/// renders as a Steam profile link rather than a bare number. +/// +/// Supplies the cached names. +internal sealed class ClanNameResolver(IClanStore store) : IClanNameResolver +{ + /// + public async Task> ResolveAsync(ulong guildId, + Guid serverId, + IReadOnlyCollection steamIds, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(steamIds); + if (steamIds.Count == 0) + { + return new Dictionary(); + } + + var known = await store.GetNamesAsync(guildId, serverId, steamIds, cancellationToken).ConfigureAwait(false); + + var result = new Dictionary(steamIds.Count); + foreach (var id in steamIds) + { + result[id] = known.TryGetValue(id, out var name) && !string.IsNullOrWhiteSpace(name) + ? name + : ProfileLink(id); + } + + return result; + } + + private static string ProfileLink(ulong steamId) + { + var id = steamId.ToString(CultureInfo.InvariantCulture); + return string.Create(CultureInfo.InvariantCulture, $"[{id}](https://steamcommunity.com/profiles/{id})"); + } +} +``` + +- [ ] **Step 4: Implement the component ids** + +`src/RustPlusBot.Features.Clans/ClanComponentIds.cs`: + +```csharp +namespace RustPlusBot.Features.Clans; + +/// Stable Discord component ids for the clan surfaces. +internal static class ClanComponentIds +{ + /// Prefix of the Set MOTD button id; the server id is appended. + public const string SetMotdButtonPrefix = "clan:motd:"; + + /// Prefix of the Set MOTD modal id; the server id is appended. + public const string SetMotdModalPrefix = "clan:motdmodal:"; + + /// Id of the MOTD text input inside the modal. + public const string MotdInputId = "clan:motd:text"; +} +``` + +- [ ] **Step 5: Implement the overview renderer** + +`Messages/ClanOverviewMessageRenderer.cs`. Shape it on `ServerInfoMessageRenderer`: + +- Guard: `if (context.ServerId is not Guid serverId) return new MessagePayload(null, null, null);` +- Load `await store.GetAsync(context.GuildId, serverId, ct)`. When null, return an empty payload — the channel only exists while a clan does, so "no clan" here means the teardown reconcile has not run yet, and leaving the old embed briefly is better than posting a contradiction. +- Title: `clan.overview.title` formatted with the clan name. +- Colour: `clan.Color is { } argb ? new Color((uint)argb & 0x00FFFFFFu) : Color.DarkGrey`. Mask off the alpha byte — Discord embed colours are 24-bit and a packed ARGB value passed raw renders as the wrong colour. +- Fields (all labels via `localizer.Get`, all numbers via `ToString(CultureInfo.InvariantCulture)`): + - `clan.overview.score` — `Score?.ToString(...)` or `clan.overview.unknown` + - `clan.overview.members` — `"{count}/{max}"`, or just the count when `MaxMemberCount` is null + - `clan.overview.created` — Discord relative timestamp: `TimestampTag.FromDateTimeOffset(clan.Created, TimestampTagStyles.Relative)` + - `clan.overview.leader` — the member holding the lowest-`Rank` role, resolved through `IClanNameResolver`. When the creator is a different id, add a `clan.overview.creator` field too. + - `clan.overview.motd` — the MOTD, plus author name and a relative timestamp, or `clan.overview.nomotd` +- Components: a `ButtonStyle.Primary` button labelled `clan.overview.setmotd` with id `$"{ClanComponentIds.SetMotdButtonPrefix}{serverId}"`, included **only** when the active player is a clan member whose role has `CanSetMotd`. Resolve the active player's Steam id through the existing credential/connection store the way other per-server surfaces do; if that lookup fails, omit the button — never show an action that will be rejected. + +- [ ] **Step 6: Implement the roster renderer** + +`Messages/ClanRosterMessageRenderer.cs`: + +- Same guards as the overview. +- Resolve every member's name in ONE `IClanNameResolver.ResolveAsync` call — not one call per member. +- Group members by `RoleId`, order groups by the role's `Rank` ascending, and order within a group by `Online` descending then `Joined` ascending. +- Members whose `RoleId` is absent from `Roles` go in a final group titled `clan.roster.unknownrole`. +- Each group is an `AddField(roleHeader, body)` where `roleHeader` is the role name plus a permission legend built from the role's flags (only the flags that are true, joined with " · ", each label localized as `clan.roster.perm.`), and `body` is one line per member: + - online: `🟢 {name}` plus `clan.roster.joined` with a relative timestamp + - offline: `⚫ {name}` plus `clan.roster.lastseen` with a relative timestamp + - append `clan.roster.notes` with the note text when `Notes` is non-blank +- Rust caps clan size, but a Discord embed field value caps at 1024 characters. If a group's body would exceed that, truncate at a line boundary and append `clan.roster.truncated` with the count of omitted members — silently dropping members would misrepresent the roster. + +- [ ] **Step 7: Implement the invites renderer** + +`Messages/ClanInvitesMessageRenderer.cs`: + +- Same guards. +- `if (clan.Invites.Count == 0) return new MessagePayload(null, null, null);` — an empty payload means the reconciler never posts the message at all. +- One resolver call covering both invitee and recruiter ids. +- Description: one line per invite via `clan.invites.line` with the invitee name, the recruiter name, and a relative timestamp. + +- [ ] **Step 8: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` +Expected: PASS, 56 total (36 + 4 resolver + 16 renderer). + +- [ ] **Step 9: Commit** + +```bash +git add src/RustPlusBot.Features.Clans tests/RustPlusBot.Features.Clans.Tests +git commit -m "$(cat <<'EOF' +Render the clan overview, roster and invites embeds + +Add the three anchored #claninfo embeds and the name resolver behind them, +which falls back to a Steam profile link for ids we have never seen a name +for. The invites embed renders empty when there are none so the reconciler +skips posting it. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 10: The Set MOTD button and modal + +**Files:** +- Create: `src/RustPlusBot.Features.Clans/Modules/ClanMotdModal.cs` +- Create: `src/RustPlusBot.Features.Clans/Modules/ClanMotdModule.cs` +- Create: `src/RustPlusBot.Features.Clans/Writing/IClanMotdWriter.cs` +- Create: `src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs` +- Create: `tests/RustPlusBot.Features.Clans.Tests/Writing/ClanMotdWriterTests.cs` + +**Interfaces:** +- Consumes: `ClanComponentIds` (T9), `IClanStore` (T4), `IRustServerConnection.SetClanMotdAsync` (T2). +- Produces: `internal interface IClanMotdWriter { Task SetAsync(ulong guildId, Guid serverId, ulong actorSteamId, string motd, CancellationToken cancellationToken); }` and `internal enum ClanMotdWriteResult { Ok, NotConnected, NotPermitted, Failed }`. + +Interaction modules are Discord.Net-bound and effectively untestable in this codebase (no other feature unit-tests one), so all decision logic lives in `ClanMotdWriter`, which IS tested. The module only marshals the interaction. + +The supervisor already owns the live sockets, so `ClanMotdWriter` needs a way to reach `SetClanMotdAsync`. Extend the existing `IRustServerQuery` seam in `Abstractions` with: + +```csharp + /// Sets the clan message of the day on a live socket. + /// The owning guild snowflake. + /// The target server id. + /// The new message of the day. + /// A cancellation token. + /// True on success; false when not connected or the write failed. + Task SetClanMotdAsync(ulong guildId, Guid serverId, string motd, CancellationToken cancellationToken); +``` + +and implement it on the supervisor's `IRustServerQuery` implementation exactly like the sibling write methods (`SetSmartSwitchValueAsync` etc.): look up `_liveSockets`, return false when absent, otherwise delegate with `_options.HeartbeatTimeout`. + +- [ ] **Step 1: Write the failing tests** + +`tests/RustPlusBot.Features.Clans.Tests/Writing/ClanMotdWriterTests.cs`: + +```csharp +[Fact] public async Task Rejects_a_writer_with_no_stored_clan() +// store.GetAsync => null => NotPermitted. +[Fact] public async Task Rejects_an_actor_who_is_not_a_clan_member() +[Fact] public async Task Rejects_an_actor_whose_role_cannot_set_the_motd() +[Fact] public async Task Rejects_an_actor_whose_role_id_is_unknown() +// A member pointing at a role missing from Roles must not be assumed permitted. +[Fact] public async Task Writes_the_motd_when_the_actor_is_permitted() +// Assert query.SetClanMotdAsync received the exact text and the result is Ok. +[Fact] public async Task Reports_Failed_when_the_socket_write_fails() +[Fact] public async Task Trims_and_rejects_a_blank_motd() +// " " => Failed, and the socket is never touched. +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1 --filter FullyQualifiedName~ClanMotdWriterTests` +Expected: FAIL — `ClanMotdWriter` does not exist. + +- [ ] **Step 3: Implement the writer** + +```csharp +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.Writing; + +/// The outcome of a clan MOTD write. +internal enum ClanMotdWriteResult +{ + /// The MOTD was set. + Ok = 0, + + /// There is no live socket for that server. + NotConnected = 1, + + /// The acting player's clan role does not allow setting the MOTD. + NotPermitted = 2, + + /// The write was attempted and failed. + Failed = 3, +} + +/// +/// Applies a clan MOTD change, enforcing the acting player's clan permission before touching the +/// socket. The permission check is duplicated here rather than trusted from the button's presence: +/// component payloads can be forged, and roles can change between render and click. +/// +/// Supplies the stored clan snapshot for the permission check. +/// Performs the write on the live socket. +internal sealed class ClanMotdWriter(IClanStore store, IRustServerQuery query) : IClanMotdWriter +{ + /// + public async Task SetAsync(ulong guildId, + Guid serverId, + ulong actorSteamId, + string motd, + CancellationToken cancellationToken) + { + var trimmed = motd?.Trim() ?? string.Empty; + if (trimmed.Length == 0) + { + return ClanMotdWriteResult.Failed; + } + + var clan = await store.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (clan is null) + { + return ClanMotdWriteResult.NotPermitted; + } + + var member = clan.Members.FirstOrDefault(m => m.SteamId == actorSteamId); + if (member is null) + { + return ClanMotdWriteResult.NotPermitted; + } + + var role = clan.Roles.FirstOrDefault(r => r.RoleId == member.RoleId); + if (role is null || !role.CanSetMotd) + { + return ClanMotdWriteResult.NotPermitted; + } + + var ok = await query.SetClanMotdAsync(guildId, serverId, trimmed, cancellationToken).ConfigureAwait(false); + return ok ? ClanMotdWriteResult.Ok : ClanMotdWriteResult.Failed; + } +} +``` + +`NotConnected` is currently unreachable because `IRustServerQuery.SetClanMotdAsync` collapses "no socket" into false. That is deliberate: the user-facing message for both is the same, and widening the query seam's return type for a distinction nothing acts on is not worth it. Keep the enum member — it documents the state and costs nothing. + +- [ ] **Step 4: Implement the modal and module** + +`Modules/ClanMotdModal.cs`, modelled on `SwitchRenameModal`: + +```csharp +using Discord; +using Discord.Interactions; + +namespace RustPlusBot.Features.Clans.Modules; + +/// The modal that collects a new clan MOTD. Handled by . +public sealed class ClanMotdModal : IModal +{ + /// The new message of the day. + [InputLabel("Message of the day")] + [ModalTextInput(ClanComponentIds.MotdInputId, TextInputStyle.Paragraph, maxLength: 1024)] + public string Motd { get; set; } = string.Empty; + + /// + public string Title => "Set clan MOTD"; +} +``` + +`Modules/ClanMotdModule.cs`, modelled on `SwitchComponentModule`: + +- `[ComponentInteraction(ClanComponentIds.SetMotdButtonPrefix + "*")] public async Task OpenAsync(string tail)` — `await RespondWithModalAsync(ClanComponentIds.SetMotdModalPrefix + tail)`. +- `[ModalInteraction(ClanComponentIds.SetMotdModalPrefix + "*")] public async Task SubmitAsync(string tail, ClanMotdModal modal)` — parse `tail` as a `Guid` with `Guid.TryParse`; on failure respond ephemerally with the localized error and return. Otherwise `DeferAsync(ephemeral: true)`, open a scope from `IServiceScopeFactory`, resolve the acting player's Steam id for `(guild, server)` from the credential store, call `IClanMotdWriter.SetAsync`, then `FollowupAsync` with the localized string for the result, ephemeral. On `Ok`, also resolve `IServerInfoRefresher` and refresh so the overview embed updates immediately rather than at the next tick. +- Both handlers guard `Context.Guild is null` the way `SettingsComponentModule` does. +- No `[RequireUserPermission]`: clan permission is enforced by the writer against the *in-game* role, which is the authority here, not a Discord permission. + +Register the assembly for module discovery in Task 11's DI, via `services.AddSingleton(new InteractionModuleAssembly(typeof(ClansServiceCollectionExtensions).Assembly));`. + +- [ ] **Step 5: Run the tests** + +Run: `dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` +Expected: PASS, 63 total. + +Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `0 Error(s)`. + +- [ ] **Step 6: Commit** + +```bash +git add src/RustPlusBot.Abstractions src/RustPlusBot.Features.Connections \ + src/RustPlusBot.Features.Clans tests/RustPlusBot.Features.Clans.Tests +git commit -m "$(cat <<'EOF' +Add the clan MOTD button and modal + +Route the Set MOTD interaction through ClanMotdWriter, which re-checks the +acting player's in-game clan permission rather than trusting the button's +presence, then writes through a new IRustServerQuery method. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 11: Clan state, the change feed, and module wiring + +The task that makes everything live: apply snapshots, drive the capability, post the feed, and register it all. + +**Files:** +- Create: `src/RustPlusBot.Features.Clans/State/ClanStateService.cs` +- Create: `src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs` +- Create: `src/RustPlusBot.Features.Clans/Posting/IClanFeedPoster.cs` +- Create: `src/RustPlusBot.Features.Clans/Posting/DiscordClanFeedPoster.cs` +- Create: `src/RustPlusBot.Features.Clans/Messages/ClanChangeRenderer.cs` +- Create: `src/RustPlusBot.Features.Clans/Hosting/ClansHostedService.cs` +- Create: `src/RustPlusBot.Features.Clans/ClansServiceCollectionExtensions.cs` +- Create: `src/RustPlusBot.Features.Workspace/Locating/IClanInfoChannelLocator.cs` and `ClanInfoChannelLocator.cs` (same shape as the clan-chat locator; register it in `WorkspaceServiceCollectionExtensions`) +- Create: `tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs` +- Create: `tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs` + +**Interfaces:** +- Consumes: everything from Tasks 1–10. +- Produces: + - `internal sealed class ClanStateService` with `Task ApplyAsync(ClanStateChangedEvent evt, CancellationToken ct)`. + - `internal sealed class ClanCapabilityProvider : IWorkspaceCapabilityProvider` (`Capability => WorkspaceCapabilities.Clan`). + - `internal interface IClanFeedPoster { Task PostAsync(ulong channelId, string text, CancellationToken ct); }`. + - `internal sealed class ClanChangeRenderer` with `string? Render(ClanChange change, IReadOnlyDictionary names, string culture)`. + - `public static IServiceCollection AddClans(this IServiceCollection services)`. + +**`ClanStateService.ApplyAsync` contract** — the heart of the feature: + +| `evt.Status` | Behaviour | +| --- | --- | +| `Unavailable` | Return immediately. Change nothing, post nothing, reconcile nothing. | +| `NoClan` | `ClearAsync`. If it returned false (no row existed), return — nothing changed. Otherwise post the `Dissolved` feed line, then `ReconcileServerAsync` so the channels are torn down. Post BEFORE reconciling: the channel is about to be deleted. | +| `HasClan` | Load the previous snapshot, `SaveAsync` the new one, diff, harvest member names, post each rendered change. If the previous snapshot was null (first detection), also `ReconcileServerAsync` so the channels get created. | + +Reconcile is called only on a **transition** (none→clan, clan→none), never on every snapshot: `OnClanChanged` fires on any clan edit, and reconciling on each one would hammer Discord. + +- [ ] **Step 1: Write the failing tests** + +`tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs`: + +```csharp +[Fact] public async Task Unavailable_changes_nothing() +// store.ClearAsync/SaveAsync never called; reconciler never called; poster never called. +[Fact] public async Task NoClan_clears_the_state_and_reconciles() +[Fact] public async Task NoClan_posts_the_dissolved_line_before_reconciling() +// Use Received.InOrder to assert poster then reconciler. +[Fact] public async Task NoClan_does_nothing_when_there_was_no_stored_clan() +// ClearAsync returns false => no reconcile, no post. +[Fact] public async Task First_detection_saves_and_reconciles_without_posting_changes() +// Previous null => SaveAsync called, ReconcileServerAsync called, poster NOT called. +[Fact] public async Task A_subsequent_snapshot_saves_and_posts_changes_without_reconciling() +// Previous non-null with a different name => SaveAsync called, poster called once, +// ReconcileServerAsync NOT called. +[Fact] public async Task Posts_nothing_when_the_snapshot_is_unchanged() +[Fact] public async Task Does_not_post_when_the_claninfo_channel_is_not_provisioned() +// Locator returns null => poster never called, but the snapshot is still saved. +``` + +`tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs` — modelled on `ChatRegistrationTests.cs`: + +```csharp +[Fact] public void Resolves_every_registered_clan_service() +// Real ServiceCollection + the substitutes AddClans needs, then +// BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }) and resolve each. +[Fact] public void Shares_one_dedup_buffer_between_the_relay_and_the_processor() +[Fact] public void Shares_the_dedup_buffer_with_the_team_chat_bridge() +// Call AddChat() and AddClans() on one collection; assert a single RelayDedupBuffer instance. +[Fact] public void Registers_the_clan_capability_provider() +// Resolve IEnumerable and assert one has Capability == "clan". +[Fact] public void Registers_the_three_clan_message_renderers() +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` +Expected: FAIL — the types do not exist. + +- [ ] **Step 3: Implement the change renderer** + +`Messages/ClanChangeRenderer.cs` — a small class taking `ILocalizer`, mapping each `ClanChangeKind` to its `clan.event.*` key and arguments: + +```csharp + /// Renders one change into a feed line, or null when it should not be posted. + /// The change to render. + /// Resolved display names covering every id referenced by the change. + /// The guild culture. + /// The feed line, or null. + public string? Render(ClanChange change, IReadOnlyDictionary names, string culture) +``` + +Key mapping, one localized key per kind: + +| Kind | Key | Arguments | +| --- | --- | --- | +| `Dissolved` | `clan.event.dissolved` | clan name | +| `Renamed` | `clan.event.renamed` | new name | +| `MotdChanged` | `clan.event.motd` | author name, new MOTD | +| `MemberJoined` | `clan.event.joined` | member name | +| `MemberLeft` | `clan.event.left` | member name | +| `MemberPromoted` | `clan.event.promoted` | member name, role name | +| `MemberDemoted` | `clan.event.demoted` | member name, role name | +| `InviteSent` | `clan.event.invited` | invitee name, recruiter name | +| `InviteAccepted` | `clan.event.inviteaccepted` | member name | +| `InviteRevoked` | `clan.event.inviterevoked` | invitee name | +| `LogoChanged` | `clan.event.logo` | — | +| `ColorChanged` | `clan.event.color` | — | +| `ScoreChanged` | `clan.event.score` | new score, `ToString(CultureInfo.InvariantCulture)` | + +Missing names fall back to `names[id]` being absent — use `names.TryGetValue(id, out var n) ? n : id.ToString(CultureInfo.InvariantCulture)` so a rendering never throws. `MotdChanged` with a null `Text` renders `clan.event.motdcleared` instead. + +**Score throttling:** the spec calls for at most one score post per refresh interval. Implement it in `ClanStateService`, not here: keep a `ConcurrentDictionary<(ulong, Guid), DateTimeOffset>` of the last score post driven by `IClock`, and drop a `ScoreChanged` change when the last one was under 60 seconds ago. Score moves on every kill; unthrottled it would drown the feed. + +- [ ] **Step 4: Implement the poster and locator** + +`Posting/DiscordClanFeedPoster.cs` — a thin `DiscordSocketClient` wrapper: resolve the channel, `SendMessageAsync(text, allowedMentions: AllowedMentions.None)`, wrapped in the standard broad catch + `[LoggerMessage]`. Feed lines are plain bot messages, not webhook impersonation — they are the bot speaking, not a player. + +`IClanInfoChannelLocator` / `ClanInfoChannelLocator` in Workspace — copy the clan-chat pair, keyed on `WorkspaceChannelKeys.ServerClanInfo`. It needs only `GetChannelIdAsync`; keep `ResolveAsync` off this one, since nothing reads Discord messages from `#claninfo`. + +- [ ] **Step 5: Implement the state service** + +`State/ClanStateService.cs` — a singleton taking `IServiceScopeFactory`, `IClanInfoChannelLocator`, `IClanFeedPoster`, `ClanChangeRenderer`, `IClock`, and a logger. It resolves `IClanStore`, `IClanNameResolver`, `IWorkspaceStore` (for the culture) and `IWorkspaceReconciler` from a fresh scope per call — never captured. All four are scoped, so capturing any of them on this singleton would be a captive dependency that `ValidateScopes = true` catches. Implement exactly the table above. + +Name harvesting: before rendering, collect every `SteamId`/`ActorSteamId` referenced by the diff plus every member in the new snapshot, and resolve them in one `IClanNameResolver` call. + +- [ ] **Step 6: Implement the capability provider** + +```csharp +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.State; + +/// +/// Reports the "clan" capability as available exactly while a clan snapshot is stored for the +/// server, which is what gates the two clan channels. +/// +/// Opens scopes for the scoped clan store. +internal sealed class ClanCapabilityProvider(IServiceScopeFactory scopeFactory) : IWorkspaceCapabilityProvider +{ + /// + public string Capability => WorkspaceCapabilities.Clan; + + /// + public async ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken) + { + // Clans are per-server; the global scope never has clan channels. + if (serverId is not Guid id) + { + return false; + } + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + return await store.HasClanAsync(guildId, id, cancellationToken).ConfigureAwait(false); + } + } +} +``` + +`IWorkspaceCapabilityProvider` is `internal` to `Features.Workspace`, so add an `InternalsVisibleTo("RustPlusBot.Features.Clans")` to the Workspace project — matching how it already exposes internals to its test assembly. (If you prefer not to widen that, promote `IWorkspaceCapabilityProvider`, `ChannelSpec.Capability` and `WorkspaceCapabilities` to public instead; pick one and be consistent.) + +- [ ] **Step 7: Implement the hosted service** + +`Hosting/ClansHostedService.cs` — copy `ChatHostedService` and extend it to **two** consumer loops plus the Discord listener: + +- loop 1: `SubscribeAsync` → `relay.RelayAsync`, and also record the sender's name via `IClanStore.RecordNameAsync` (this is the main name-harvesting path). +- loop 2: `SubscribeAsync` → `stateService.ApplyAsync`. +- `MessageReceived` → `ClanChatInboundProcessor`, ❌ on `InboundOutcome.Failed`. + +Both loops follow the mandated shape verbatim: `Task.Run` in `StartAsync`, `await foreach`, `OperationCanceledException` swallowed, broad catch with the CA1031 pragma and a `[LoggerMessage]` partial. `StopAsync` cancels then awaits **both** loop tasks. + +- [ ] **Step 8: Implement `AddClans()`** + +```csharp +public static IServiceCollection AddClans(this IServiceCollection services) +{ + ArgumentNullException.ThrowIfNull(services); + + // Shared with AddChat: TryAdd so the two bridges get one buffer in either composition order. + services.TryAddSingleton(); + + services.AddRustPlusBotLocalization(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Scoped: these reach the DbContext through IClanStore. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(new InteractionModuleAssembly(typeof(ClansServiceCollectionExtensions).Assembly)); + services.AddHostedService(); + + return services; +} +``` + +`ClanStateService` and `ClanCapabilityProvider` are singletons that need scoped stores — both resolve them per call from `IServiceScopeFactory`. The registration test's `ValidateScopes = true` is what proves no captive dependency slipped in. + +- [ ] **Step 9: Run the tests** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` +Expected: PASS, 76 total (63 + 8 state + 5 registration). + +Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `0 Error(s)`. + +- [ ] **Step 10: Commit** + +```bash +git add src/RustPlusBot.Features.Clans src/RustPlusBot.Features.Workspace tests/RustPlusBot.Features.Clans.Tests +git commit -m "$(cat <<'EOF' +Apply clan state, post the change feed, and wire the module + +Persist each snapshot, drive the workspace clan capability from its presence, +and post diffed changes into #claninfo. Reconcile runs only on a none-to-clan +or clan-to-none transition, and an Unavailable probe changes nothing at all. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 12: Localization + +**Files:** +- Modify: `src/RustPlusBot.Localization/Strings.resx` +- Modify: `src/RustPlusBot.Localization/Strings.fr.resx` + +**Interfaces:** +- Consumes: every `clan.*` and `channel.clan*` key referenced in Tasks 9–11. +- Produces: nothing code-facing. + +- [ ] **Step 1: Collect the exact key list** + +```bash +grep -rhoE '"(channel\.clan[a-z.]*|clan\.[a-z.]+|server\.clan[a-z.]*)"' src/RustPlusBot.Features.Clans \ + | tr -d '"' | sort -u +``` + +Every key that command prints MUST exist in both resx files. Work from that list, not from memory. + +- [ ] **Step 2: Add every key to `Strings.resx`** + +Follow the file's existing `...` formatting exactly. English copy, using `{0}`/`{1}` positional placeholders matching each `localizer.Get(key, culture, arg0, arg1)` call site. Channel names must be lowercase and Discord-safe (no spaces): `clanchat`, `clan-info`. + +- [ ] **Step 3: Add every key to `Strings.fr.resx`** + +Same key set, French copy, **same placeholder count and order** in every string. A mismatched placeholder count throws a `FormatException` at render time, not at build time — check each pair. + +- [ ] **Step 4: Verify parity** + +Run: `dotnet test tests/RustPlusBot.Localization.Tests -maxcpucount:1` +Expected: PASS. `StringsResourceParityTests` fails loudly on any key present in one file and not the other. + +- [ ] **Step 5: Commit** + +```bash +git add src/RustPlusBot.Localization +git commit -m "$(cat <<'EOF' +Localize the clan channels, embeds and change feed + +Add the English and French strings for the clan channel names, the three +anchored embeds, the MOTD modal, and every clan feed event. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 13: Host wiring and final verification + +**Files:** +- Modify: `src/RustPlusBot.Host/Program.cs` +- Modify: `src/RustPlusBot.Host/RustPlusBot.Host.csproj` +- Modify: `README.md` + +**Interfaces:** +- Consumes: `AddClans()` (T11). +- Produces: a composed, running bot. + +- [ ] **Step 1: Compose the module** + +Add a `ProjectReference` to `RustPlusBot.Features.Clans` in `RustPlusBot.Host.csproj`, matching the existing entries' formatting. + +In `Program.cs`, add immediately after `builder.Services.AddChat();`: + +```csharp +builder.Services.AddClans(); +``` + +Order matters only in that `AddChat()` and `AddClans()` both register `RelayDedupBuffer` — `AddChat` uses `AddSingleton` and `AddClans` uses `TryAddSingleton`, so this order yields one instance. The registration test in Task 11 covers it either way. + +No new options section: the clan embeds refresh on the existing `Workspace` interval, so there is nothing to bind or validate. + +- [ ] **Step 2: Verify the composed graph starts** + +Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `0 Warning(s) 0 Error(s)`. + +Run: `dotnet run --project src/RustPlusBot.Host -- --help 2>&1 | head -20` (or start the host with an invalid Discord token and confirm it fails on token validation, not on DI). +Expected: the host reaches Discord login. A `ValidateOnStart` or DI resolution failure here means a captive dependency the registration test did not cover — fix it before continuing. + +- [ ] **Step 3: Run the entire suite and read per-assembly counts** + +Run: `dotnet test RustPlusBot.slnx -maxcpucount:1` + +Expected: every assembly passes. **Read the per-assembly counts.** `RustPlusBot.Features.Clans.Tests` must report **76**, not 0. An assembly reporting 0 means it failed to build and its tests silently did not run. + +Record the actual per-assembly numbers in the commit message or PR body — not "all tests pass". + +- [ ] **Step 4: Document the feature** + +Add a short section to `README.md` alongside the other feature descriptions, matching their tone and length: + +- clan channels appear automatically when the paired player is in a clan and are removed when they leave +- `#clanchat` is bidirectional +- `#claninfo` carries pinned overview/roster/invite embeds plus a live change feed +- the Set MOTD button respects the player's in-game clan role +- note the API limits: no clan audit log, no per-member scores, no kick/invite/promote from Discord + +- [ ] **Step 5: Run the formatting gate** + +This is slow. Run it ONCE, here, and nowhere earlier. + +```bash +dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder" +git diff --stat +``` + +If the tool changed files, review the diff, then re-run the build and the full test suite before committing. CI fails on any diff this tool would produce. + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "$(cat <<'EOF' +Compose the clan module into the host + +Register AddClans, document the feature and its API limits in the README, and +apply the repository formatting profile. + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +- [ ] **Step 7: Manual verification checklist** + +Automated tests cannot cover the Discord and Rust+ integration surfaces. Against a live server, confirm: + +1. A paired player with **no** clan produces **no** `#clanchat` and **no** `#claninfo`. +2. Creating a clan in game makes both channels appear within one reconcile, with the three embeds pinned. +3. A message in game reaches `#clanchat`; a message in `#clanchat` reaches the game exactly once (no double-post, no echo loop). +4. `!`-prefixed lines and `[R+]` lines stay out of `#clanchat`. +5. A member joining, being promoted, and leaving each produce exactly one feed line. +6. Changing the MOTD in game posts a feed line and updates the overview embed. +7. The Set MOTD button appears only for a player whose in-game role permits it, and writing through it changes the MOTD in game. +8. Leaving the clan deletes both channels and leaves no orphaned rows (verify `ClanStates`, `ProvisionedChannel` and `ProvisionedMessage` in the DB). +9. Killing the Rust+ socket mid-session does **not** delete the channels — the Unavailable path preserves state. +10. Switching the guild language to French re-renders both channel names and all embeds. + +--- + +## Self-Review + +**Spec coverage.** Every spec section maps to a task: capability survey → the Verified API facts block; scope model → Task 1; detection → Tasks 2–3; conditional channels → Task 5; `#clanchat` → Tasks 6–7; `#claninfo` embeds → Task 9; name resolution → Task 9; refresh → Task 5 Step 11; event feed → Tasks 8 and 11; persistence → Task 4; localization → Task 12; error handling → distributed, with the `Unavailable` rule enforced in Tasks 2, 3 and 11; testing → every task; build constraints → Global Constraints and Task 13. + +**Deviation from the spec, resolved in the spec.** The spec originally called for a clan logo thumbnail on the overview embed. `MessagePayload` carries no attachment, so rendering it would mean threading file uploads through `IWorkspaceGateway` and `RenderCanonicalizer` — a large change to shared provisioning code for a decorative image. The spec has been amended to drop the thumbnail while keeping the logo hash, so `LogoChanged` feed events still work. + +**Known unreachable code.** `ClanMotdWriteResult.NotConnected` cannot currently be returned, because `IRustServerQuery.SetClanMotdAsync` collapses "no live socket" into `false`. Documented at its definition in Task 10. + +**Two additions beyond the spec's file list**, both forced by the code as it actually stands: +- `IWorkspaceStore.DeleteChannelAsync` (Task 5) — the store had only `DeleteScopeAsync`, so removing a single capability-gated channel had no persistence path. +- Moving `RelayDedupBuffer` into `Abstractions` (Task 6) — it was `internal` to `Features.Chat`, and the spec requires one shared instance across both bridges. diff --git a/docs/superpowers/specs/2026-07-21-clan-support-design.md b/docs/superpowers/specs/2026-07-21-clan-support-design.md index 610ebb1a..ba902cba 100644 --- a/docs/superpowers/specs/2026-07-21-clan-support-design.md +++ b/docs/superpowers/specs/2026-07-21-clan-support-design.md @@ -42,17 +42,29 @@ on already exists; no library change is required. **Error code:** `RustPlusErrorCode.NoClan` (`no_clan`) — the player is not in a clan. This is the authoritative negative-detection signal. -**Models (`RustPlusApi.Data.Clans`):** - -- `ClanInfo` — `ClanId`, `Name`, `Created`, `Creator`, `Motd`, `MotdTimestamp`, `MotdAuthor`, - `Logo` (raw bytes), `Color` (packed ARGB), `Roles`, `Members`, `Invites`, - `MaxMemberCount`, `Score` -- `ClanMember` — `SteamId`, `RoleId`, `Joined`, `LastSeen`, `Notes`, `Online` -- `ClanRole` — `RoleId`, `Rank` (lower = higher rank), `Name`, and permission flags - `CanSetMotd`, `CanSetLogo`, `CanInvite`, `CanKick`, `CanPromote`, `CanDemote`, - `CanSetPlayerNotes`, `CanAccessLogs`, `CanAccessScoreEvents` -- `ClanInvite` — `SteamId`, `Recruiter`, `Timestamp` -- `ClanMessage` — `SteamId`, `Name`, `Message`, `Time` +**Models (`RustPlusApi.Data.Clans`)** — exact CLR types, verified by reflecting over the +shipped assembly: + +- `ClanInfo` — `long ClanId`, `string Name`, `DateTime Created`, `ulong Creator`, + `string? Motd`, `DateTime? MotdTimestamp`, `ulong? MotdAuthor`, `byte[]? Logo`, + `int? Color` (packed ARGB), `IEnumerable Roles`, + `IEnumerable Members`, `IEnumerable Invites`, + `int? MaxMemberCount`, `long? Score` +- `ClanMember` — `ulong SteamId`, `int RoleId`, `DateTime Joined`, `DateTime LastSeen`, + `string? Notes`, **`bool? Online`** (nullable — treat `null` as offline) +- `ClanRole` — `int RoleId`, `int Rank` (lower = higher rank), `string Name`, and eight + `bool` permission flags: `CanSetMotd`, `CanSetLogo`, `CanInvite`, `CanKick`, + `CanPromote`, `CanDemote`, `CanSetPlayerNotes`, `CanAccessLogs`, `CanAccessScoreEvents` +- `ClanInvite` — `ulong SteamId`, `ulong Recruiter`, `DateTime Timestamp` +- `ClanMessage` — `ulong SteamId`, `string Name`, `string Message`, `DateTime Time` +- `ClanMessageEventArg` is **flat**, not nested: `long ClanId`, `ulong SteamId`, + `string Name`, `string Message`, `DateTime Time` + +All collections are `IEnumerable` and all timestamps are `DateTime`, so the mapping layer +materialises them to `IReadOnlyList` and `DateTimeOffset` (assuming UTC) at the boundary. + +Requests return `Response` with `IsSuccess`, `Data`, and `Error?.Code` +(`RustPlusErrorCode`) — the same shape the existing socket source already consumes. **Deliberately out of scope — no API exists.** `CanAccessLogs` and `CanAccessScoreEvents` are readable permission flags, but the library exposes no clan audit-log fetch and no @@ -236,8 +248,11 @@ Workspace's `ServerWorkspaceSpecProvider`. - title: clan name; embed colour unpacked from `ClanInfo.Color` ARGB, falling back to the neutral colour when unset -- thumbnail: `Logo` bytes uploaded as an attachment when present, hashed so an unchanged - logo is not re-uploaded +- no logo thumbnail. `ClanInfo.Logo` is raw bytes, and `MessagePayload` carries only + `(Text, Embed, Components)` — no attachment. Rendering the logo would mean threading file + uploads through `IWorkspaceGateway.PostMessageAsync`/`EditMessageAsync` and + `RenderCanonicalizer`, a substantial change to shared provisioning code for a decorative + thumbnail. The logo is still hashed so the feed can report "logo changed". - fields: Score · Members (`n/MaxMemberCount`) · Created (Discord relative timestamp) · Leader (holder of the lowest-`Rank` role; `Creator` shown separately when different) · MOTD with author name and relative timestamp From 8ae1acccbb673d486edf8df8f74bf323aaebc9df Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 22:36:08 +0200 Subject: [PATCH 03/39] Replace the cloned clan bridge with a kind-driven shared bridge Rework the plan and spec so one ChatRelay, ChatInboundProcessor and webhook poster serve both team and clan chat via a ChatChannelKind discriminator, replacing ITeamChatSender and ITeamChatChannelLocator with unified seams. Features.Clans now owns no chat code. Also drop the unreachable ClanMotdWriteResult.NotConnected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-21-clan-support.md | 667 ++++++++++-------- .../specs/2026-07-21-clan-support-design.md | 41 +- 2 files changed, 396 insertions(+), 312 deletions(-) diff --git a/docs/superpowers/plans/2026-07-21-clan-support.md b/docs/superpowers/plans/2026-07-21-clan-support.md index 5a1afbe7..fe06b677 100644 --- a/docs/superpowers/plans/2026-07-21-clan-support.md +++ b/docs/superpowers/plans/2026-07-21-clan-support.md @@ -67,14 +67,14 @@ Lower `ClanRole.Rank` = higher rank (leader is the lowest number). | File | Responsibility | | --- | --- | | `ClansServiceCollectionExtensions.cs` | The single public `AddClans()` | -| `Hosting/ClansHostedService.cs` | Two event-bus consumer loops + the Discord `MessageReceived` hook | -| `Relaying/ClanChatRelay.cs` | Game→Discord: drop echoes/commands, post via webhook | -| `Webhooks/IClanChatWebhookPoster.cs` / `DiscordClanChatWebhookPoster.cs` | Webhook impersonation | -| `Inbound/ClanChatInboundProcessor.cs` | Discord→game: mute gate, record-then-send | +| `Hosting/ClansHostedService.cs` | One event-bus consumer loop applying clan state changes | | `State/ClanStateService.cs` | Applies a `ClanStateChangedEvent`: persist, diff, trigger reconcile | | `State/ClanSnapshotDiffer.cs` | Pure `(previous, current) -> IReadOnlyList` | | `State/ClanChange.cs` | The typed change record + `ClanChangeKind` enum | | `State/ClanCapabilityProvider.cs` | `IWorkspaceCapabilityProvider` for `"clan"` | +| `Posting/IClanFeedPoster.cs` / `DiscordClanFeedPoster.cs` | Posts feed lines into `#claninfo` | + +**Note:** there is no clan chat bridge in this project. `Features.Chat` is generalised over `ChatChannelKind` in Tasks 6-7 and serves both team and clan chat from one implementation. | `Names/IClanNameResolver.cs` / `ClanNameResolver.cs` | Steam id → display name, with profile-link fallback | | `Messages/ClanOverviewMessageRenderer.cs` | `clan.overview` embed + Set MOTD button | | `Messages/ClanRosterMessageRenderer.cs` | `clan.roster` embed | @@ -319,7 +319,6 @@ EOF **Files:** - Create: `src/RustPlusBot.Features.Connections/Listening/ClanMapping.cs` - Create: `src/RustPlusBot.Features.Connections/Listening/ClanChatLine.cs` -- Create: `src/RustPlusBot.Features.Connections/Listening/IClanChatSender.cs` - Modify: `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs` - Modify: `src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs` - Test: `tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs` @@ -330,7 +329,8 @@ EOF - Produces: - `internal static class ClanMapping` with `public static ClanProbeResult FromResponse(bool isSuccess, RustPlusErrorCode? errorCode, ClanInfo? data)` and `public static ClanSnapshot ToSnapshot(ClanInfo info)` and `public static string HashLogo(byte[]? logo)`. - `internal sealed record ClanChatLine(ulong SteamId, string Name, string Message, DateTimeOffset Time)`. - - `public enum ClanChatSendResult { Sent = 0, NotConnected = 1, Failed = 2 }` and `public interface IClanChatSender { Task SendAsync(ulong guildId, Guid serverId, string message, CancellationToken cancellationToken); }`. + +**Not produced here:** there is no `IClanChatSender`. Task 6 replaces `ITeamChatSender` with a single kind-parameterised `IChatSender` that serves both channels, so a second same-shaped sender interface is never created. - New `IRustServerConnection` members: `Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken)`, `Task SendClanMessageAsync(string message, CancellationToken cancellationToken)`, `Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken)`, `event EventHandler? ClanMessageReceived`, `event EventHandler? ClanChanged`. **Note on `ClanChanged`:** the event carries `ClanProbeResult`, not `ClanSnapshot?`. `OnClanChanged` with a null `ClanInfo` maps to `ClanProbeResult.NoClan` — a definitive signal — which keeps a single type flowing from socket to supervisor to bus. @@ -588,7 +588,7 @@ Note: `CultureInfo` is imported for consistency with sibling files; if the analy Run: `dtk dotnet test tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1 --filter FullyQualifiedName~ClanMappingTests` Expected: PASS, 8 tests. -- [ ] **Step 5: Add the chat line and sender seam** +- [ ] **Step 5: Add the chat line record** `src/RustPlusBot.Features.Connections/Listening/ClanChatLine.cs`: @@ -603,40 +603,6 @@ namespace RustPlusBot.Features.Connections.Listening; internal sealed record ClanChatLine(ulong SteamId, string Name, string Message, DateTimeOffset Time); ``` -`src/RustPlusBot.Features.Connections/Listening/IClanChatSender.cs`: - -```csharp -namespace RustPlusBot.Features.Connections.Listening; - -/// The outcome of relaying a Discord message into in-game clan chat. -public enum ClanChatSendResult -{ - /// The message was handed to the live socket. - Sent = 0, - - /// There is no live socket for that (guild, server) right now. - NotConnected = 1, - - /// A live socket exists but the send failed. - Failed = 2, -} - -/// Relays a message into a server's in-game clan chat (implemented by the connection supervisor). -public interface IClanChatSender -{ - /// Sends to the live socket for (, ). - /// The owning guild snowflake. - /// The target server id. - /// The message text to relay. - /// A cancellation token. - /// The send result. - Task SendAsync(ulong guildId, - Guid serverId, - string message, - CancellationToken cancellationToken); -} -``` - - [ ] **Step 6: Extend `IRustServerConnection`** Append these members to `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs`, immediately after `SendTeamMessageAsync`: @@ -866,8 +832,8 @@ EOF - Test: `tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs` **Interfaces:** -- Consumes: `ClanChatLine`, `ClanProbeResult`, `IClanChatSender`, `ClanChatSendResult` (Task 2); `ClanMessageReceivedEvent`, `ClanStateChangedEvent` (Task 1). -- Produces: `ConnectionSupervisor` additionally implements `IClanChatSender`; publishes `ClanMessageReceivedEvent` and `ClanStateChangedEvent` on `IEventBus`. +- Consumes: `ClanChatLine`, `ClanProbeResult` (Task 2); `ClanMessageReceivedEvent`, `ClanStateChangedEvent` (Task 1). +- Produces: `ConnectionSupervisor` publishes `ClanMessageReceivedEvent` and `ClanStateChangedEvent` on `IEventBus`. Sending is NOT added here — Task 6 unifies it. Read `ConnectionSupervisorTests.cs` first — it already establishes how to stand up a supervisor over `FakeRustSocketSource` and drain the bus. Reuse that harness rather than inventing one. @@ -893,16 +859,7 @@ Read `ConnectionSupervisorTests.cs` first — it already establishes how to stan // RaiseClanMessage with SteamId == the credential's steam id => FromActivePlayer true; // with a different SteamId => false. -// 5. Sending with no live socket reports NotConnected. -[Fact] public async Task Clan_send_reports_NotConnected_without_a_live_socket() -// var result = await supervisor.SendAsync(guildId, Guid.NewGuid(), "hi", default); -// Assert.Equal(ClanChatSendResult.NotConnected, result); - -// 6. Sending with a live socket reaches the connection. -[Fact] public async Task Clan_send_reaches_the_live_socket() -// Assert fake.SentClanMessages contains the text and the result is Sent. - -// 7. Unsubscription on disconnect: after the connection loop exits, raising a clan +// 5. Unsubscription on disconnect: after the connection loop exits, raising a clan // event on the fake publishes nothing further. [Fact] public async Task Stops_publishing_clan_events_after_the_socket_closes() ``` @@ -912,45 +869,11 @@ Write these out fully against the existing harness. Do not leave them as comment - [ ] **Step 2: Run the tests to verify they fail** Run: `dotnet test tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1 --filter FullyQualifiedName~ClanSupervisorTests` -Expected: FAIL — `SendAsync` returning `ClanChatSendResult` does not exist; no clan events are published. +Expected: FAIL — no clan events are published. -- [ ] **Step 3: Implement `IClanChatSender` on the supervisor** +- [ ] **Step 3: (removed)** -Declare `IClanChatSender` on the class alongside `ITeamChatSender`. Because `ITeamChatSender.SendAsync` and `IClanChatSender.SendAsync` have identical parameter lists and differ only in return type, C# cannot host both as implicit implementations. Implement the clan one **explicitly**: - -```csharp - /// - async Task IClanChatSender.SendAsync( - ulong guildId, - Guid serverId, - string message, - CancellationToken cancellationToken) - { - if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) - { - return ClanChatSendResult.NotConnected; - } - - try - { - await live.Connection.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false); - return ClanChatSendResult.Sent; - } - catch (OperationCanceledException) - { - throw; - } -#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed. - catch (Exception ex) -#pragma warning restore CA1031 - { - LogClanSendFailed(logger, ex, serverId); - return ClanChatSendResult.Failed; - } - } -``` - -Because it is explicit, the tests must call it through the interface: `((IClanChatSender)supervisor).SendAsync(...)`. +Nothing to do here. The supervisor's send path is unified in Task 6 as a single kind-parameterised `IChatSender`, so no clan-specific sender is added in this task. Proceed to Step 4. - [ ] **Step 4: Subscribe, probe, and publish in the connected window** @@ -1065,20 +988,14 @@ And the three log methods next to the existing `[LoggerMessage]` block: private static partial void LogPublishClanStateFailed(ILogger logger, Exception exception, Guid serverId); ``` -- [ ] **Step 6: Register the sender** - -In `ConnectionServiceCollectionExtensions.cs`, next to the existing `ITeamChatSender` registration, expose the same singleton instance under the new interface: - -```csharp - services.AddSingleton(sp => sp.GetRequiredService()); -``` +- [ ] **Step 6: (removed)** -Match the exact style already used for `ITeamChatSender` in that file — if it registers via `sp.GetRequiredService()`, mirror it verbatim so both interfaces resolve to one supervisor. +No new registration. Task 6 changes the existing `ITeamChatSender` registration into `IChatSender`. - [ ] **Step 7: Run the tests** Run: `dtk dotnet test tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1` -Expected: all pass, 7 more than after Task 2. +Expected: all pass, 5 more than after Task 2. - [ ] **Step 8: Commit** @@ -1089,8 +1006,7 @@ Publish clan chat and clan state from the supervisor Subscribe to the socket's clan events for the lifetime of a connected window, probe the clan once on connect so state survives a restart, and publish both -onto the event bus. The supervisor also implements IClanChatSender explicitly, -since it differs from ITeamChatSender only by return type. +onto the event bus. Co-Authored-By: Claude Opus 4.8 (1M context) EOF @@ -1439,7 +1355,6 @@ The reconciler currently provisions every declared channel unconditionally. This **Files:** - Create: `src/RustPlusBot.Features.Workspace/Registry/IWorkspaceCapabilityProvider.cs` -- Create: `src/RustPlusBot.Features.Workspace/Locating/IClanChatChannelLocator.cs` - Create: `src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs` - Modify: `src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs` - Modify: `src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs` @@ -1466,7 +1381,7 @@ The reconciler currently provisions every declared channel unconditionally. This - `WorkspaceChannelKeys.ServerClanChat = "clanchat"`, `WorkspaceChannelKeys.ServerClanInfo = "claninfo"`. - `WorkspaceMessageKeys.ClanOverview = "clan.overview"`, `ClanRoster = "clan.roster"`, `ClanInvites = "clan.invites"`. - `WorkspaceCapabilities.Clan = "clan"`. - - `public interface IClanChatChannelLocator` with the same two members as `ITeamChatChannelLocator`. + - `internal sealed class ClanChatChannelLocator : CachingChannelLocator` over `WorkspaceChannelKeys.ServerClanChat`. It gets its `IChatChannelLocator` interface and its DI registration in Task 6, which unifies the locator seam; here it is only the class. - [ ] **Step 1: Write the failing tests** @@ -1831,15 +1746,7 @@ and add the three pinned messages to `GetMessageSpecs()`, after the existing ent new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanInvites, WorkspaceChannelKeys.ServerClanInfo, true), ``` -`IClanChatChannelLocator.cs` — copy `ITeamChatChannelLocator` verbatim, renaming the type and swapping "#teamchat" for "#clanchat" in the docs. - -`ClanChatChannelLocator.cs` — copy `TeamChatChannelLocator` verbatim, renaming the type, the base-constructor key to `WorkspaceChannelKeys.ServerClanChat`, and the implemented interface. - -Register in `WorkspaceServiceCollectionExtensions.cs` next to the other locators: - -```csharp - services.AddSingleton(); -``` +`ClanChatChannelLocator.cs` — a `CachingChannelLocator` subclass over `WorkspaceChannelKeys.ServerClanChat` with the same `ResolveAsync` reverse lookup `TeamChatChannelLocator` has. Do **not** give it a `ClanChat`-specific interface and do **not** register it yet: Task 6 introduces the shared `IChatChannelLocator` that both locators implement, and registers them as a collection. Leaving it unregistered for one task is intentional and the build stays green. - [ ] **Step 11: Refresh the clan embeds too** @@ -1890,27 +1797,63 @@ EOF --- -### Task 6: Share the dedup buffer across both bridges +### Task 6: Unify the chat sender and locator seams -`RelayDedupBuffer` is `internal` to `Features.Chat`, but the clan bridge needs the same instance — and it must not let a clan echo cancel an identical team line. Move it to `Abstractions` and key it by channel kind. This is a prerequisite for Task 7, so it lands on its own. +The clan bridge is NOT a copy of the team bridge — one kind-driven bridge serves both. This task unifies the three seams that currently hardcode "team"; Task 7 generalises the behaviour on top of them. Splitting them keeps each task at a green suite. **Files:** -- Create: `src/RustPlusBot.Abstractions/Chat/RelayDedupBuffer.cs` - Create: `src/RustPlusBot.Abstractions/Chat/ChatChannelKind.cs` +- Create: `src/RustPlusBot.Abstractions/Chat/RelayDedupBuffer.cs` - Delete: `src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs` -- Modify: `src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs` -- Modify: `src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs` -- Modify: `src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs` -- Modify: `tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs` -- Modify: `tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs`, `TeamChatInboundProcessorTests.cs`, `ChatRegistrationTests.cs` +- Create: `src/RustPlusBot.Features.Connections/Listening/IChatSender.cs` +- Delete: `src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs` +- Modify: `src/RustPlusBot.Features.Connections/Listening/BotTeamChatSender.cs`, `IBotTeamChatSender.cs` +- Modify: `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs` +- Modify: `src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs` +- Create: `src/RustPlusBot.Features.Workspace/Locating/IChatChannelLocator.cs` +- Delete: `src/RustPlusBot.Features.Workspace/Locating/ITeamChatChannelLocator.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs`, `ClanChatChannelLocator.cs`, `WorkspaceServiceCollectionExtensions.cs` +- Modify: `src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs`, `Inbound/TeamChatInboundProcessor.cs`, `ChatServiceCollectionExtensions.cs` +- Modify: the Chat and Connections test files that reference the removed types **Interfaces:** -- Consumes: `IClock` (existing). -- Produces: `public enum ChatChannelKind { Team = 0, Clan = 1 }` and `public sealed class RelayDedupBuffer(IClock clock)` with `void Record(ChatChannelKind kind, (ulong Guild, Guid Server) key, string text)` and `bool TryConsume(ChatChannelKind kind, (ulong Guild, Guid Server) key, string text)`, both in namespace `RustPlusBot.Abstractions.Chat`. +- Consumes: `IClock` (existing); `ClanChatLine` and the clan socket members (Task 2). +- Produces: + +```csharp +namespace RustPlusBot.Abstractions.Chat; +public enum ChatChannelKind { Team = 0, Clan = 1 } -- [ ] **Step 1: Write the failing test** +public sealed class RelayDedupBuffer(IClock clock) +{ + public void Record(ChatChannelKind kind, (ulong Guild, Guid Server) key, string text); + public bool TryConsume(ChatChannelKind kind, (ulong Guild, Guid Server) key, string text); +} + +namespace RustPlusBot.Features.Connections.Listening; +public enum ChatSendResult { Sent = 0, NotConnected = 1, Failed = 2 } + +public interface IChatSender +{ + Task SendAsync(ChatChannelKind kind, ulong guildId, Guid serverId, string message, CancellationToken cancellationToken); +} -Add to `tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs` (keeping every existing test, updated for the new signature): +namespace RustPlusBot.Features.Workspace.Locating; +public interface IChatChannelLocator +{ + ChatChannelKind Kind { get; } + Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); + Task<(ulong GuildId, Guid ServerId)?> ResolveAsync(ulong channelId, CancellationToken cancellationToken); +} +``` + +`ITeamChatSender`, `TeamChatSendResult` and `ITeamChatChannelLocator` are **removed**, not kept as aliases. Two names for one concept is how the duplication returns. + +Because `IChatSender.SendAsync` now takes the kind as a parameter, the supervisor implements it once as a normal (non-explicit) member — no `IClanChatSender`, and none of the explicit-implementation awkwardness a second same-shaped interface would force. + +- [ ] **Step 1: Write the failing tests** + +Update `tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs` for the new signature, keeping every existing case, and add: ```csharp [Fact] @@ -1939,14 +1882,25 @@ Add to `tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs` (keeping } ``` -Use whatever fake clock the existing tests in this file already use — do not introduce a new one. +Use whatever fake clock that file already uses. Add to `tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs` (created in Task 3): + +```csharp + [Fact] + public async Task Routes_a_team_send_to_team_chat_and_a_clan_send_to_clan_chat() + { + // One IChatSender, two destinations: assert the kind selects the right socket call and + // that neither leaks into the other's buffer. + } +``` + +Write it fully against the Task 3 harness, asserting `fake.SentTeamMessages` and `fake.SentClanMessages` independently. - [ ] **Step 2: Run to verify failure** -Run: `dtk dotnet test tests/RustPlusBot.Features.Chat.Tests -maxcpucount:1` -Expected: FAIL — compile error, `Record` takes two arguments. +Run: `dotnet test tests/RustPlusBot.Features.Chat.Tests tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1` +Expected: FAIL — compile errors; `Record` takes two arguments and `IChatSender` does not exist. -- [ ] **Step 3: Move and re-key the buffer** +- [ ] **Step 3: Move and re-key the dedup buffer** `src/RustPlusBot.Abstractions/Chat/ChatChannelKind.cs`: @@ -1964,60 +1918,116 @@ public enum ChatChannelKind } ``` -`src/RustPlusBot.Abstractions/Chat/RelayDedupBuffer.cs` — the existing implementation moved verbatim, with three changes: -1. namespace becomes `RustPlusBot.Abstractions.Chat` and the class becomes `public sealed`; -2. the dictionary key becomes `(ChatChannelKind Kind, ulong Guild, Guid Server)`; -3. `Record` and `TryConsume` take `ChatChannelKind kind` as their first parameter and build the composite key internally. +`src/RustPlusBot.Abstractions/Chat/RelayDedupBuffer.cs` — the existing implementation moved verbatim, with three changes: namespace becomes `RustPlusBot.Abstractions.Chat`; the class becomes `public sealed`; the dictionary key becomes `(ChatChannelKind Kind, ulong Guild, Guid Server)` and both methods take `ChatChannelKind kind` first. Update the class doc to explain that the kind is part of the key so an identical line relayed to both channels within the TTL cannot cross-cancel. Delete the old file. + +- [ ] **Step 4: Unify the sender** + +`src/RustPlusBot.Features.Connections/Listening/IChatSender.cs` — `ChatSendResult` and `IChatSender` exactly as in the Interfaces block, with full `///` docs. Delete `ITeamChatSender.cs`. -The class doc should now read: +On `ConnectionSupervisor`, replace `ITeamChatSender` in the base list with `IChatSender` and rewrite the existing `SendAsync` to dispatch on kind: ```csharp -/// -/// Short-lived record of lines the bridges relayed into the game, keyed by (channel kind, guild, -/// server). When the bot's active player echoes a relayed line back on the socket, -/// matches and removes one entry so the relay drops the echo instead of -/// re-posting it to Discord. The channel kind is part of the key so an identical line relayed to -/// both team and clan chat within the TTL cannot cross-cancel. -/// -/// Drives entry expiry. + /// + public async Task SendAsync( + ChatChannelKind kind, + ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return ChatSendResult.NotConnected; + } + + try + { + switch (kind) + { + case ChatChannelKind.Team: + await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); + break; + case ChatChannelKind.Clan: + await live.Connection.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false); + break; + default: + return ChatSendResult.Failed; + } + + return ChatSendResult.Sent; + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogSendFailed(logger, ex, serverId); + return ChatSendResult.Failed; + } + } ``` -Delete `src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs`. +Update the DI registration to `services.AddSingleton(sp => sp.GetRequiredService());`, the disposal comment that names `ITeamChatSender`, and `BotTeamChatSender` to take `IChatSender` and pass `ChatChannelKind.Team`. + +- [ ] **Step 5: Unify the locator** + +`src/RustPlusBot.Features.Workspace/Locating/IChatChannelLocator.cs` — as in the Interfaces block, with docs written generically ("the per-server chat channel for this kind"), not team-specific. Delete `ITeamChatChannelLocator.cs`. -- [ ] **Step 4: Update the team bridge call sites** +`TeamChatChannelLocator` implements `IChatChannelLocator` with `public ChatChannelKind Kind => ChatChannelKind.Team;`. `ClanChatChannelLocator` (created in Task 5) does the same with `Clan`, and gains the `ResolveAsync` override it already has. -`TeamChatRelay.cs` — add `using RustPlusBot.Abstractions.Chat;` and change the guard to: +Registration changes to a collection so the bridge can index by kind: ```csharp - if (evt.FromActivePlayer && dedup.TryConsume(ChatChannelKind.Team, (evt.GuildId, evt.ServerId), evt.Message)) + services.AddSingleton(); + services.AddSingleton(); ``` -`TeamChatInboundProcessor.cs` — add the using and change the record call to: +`IClanInfoChannelLocator` (Task 11) is unaffected — it is not a chat channel and keeps its own single-purpose interface. + +- [ ] **Step 6: Update the existing team bridge call sites** + +`TeamChatRelay` — take `IEnumerable` is NOT needed yet; for this task simply resolve the team locator by filtering the injected collection in the constructor: ```csharp - dedup.Record(ChatChannelKind.Team, key, text); +internal sealed class TeamChatRelay( + IEnumerable locators, + ITeamChatWebhookPoster poster, + RelayDedupBuffer dedup, + IServiceScopeFactory scopeFactory) +{ + private readonly IChatChannelLocator _locator = locators.Single(l => l.Kind == ChatChannelKind.Team); ``` -`ChatServiceCollectionExtensions.cs` — the `services.AddSingleton();` line stays, but its `using` moves from `RustPlusBot.Features.Chat.Relaying` to `RustPlusBot.Abstractions.Chat`. Keeping the registration in `AddChat()` is deliberate: `AddClans()` will register it with `TryAddSingleton` so the two features share one instance in either composition order. +and pass `ChatChannelKind.Team` to `dedup.TryConsume`. `TeamChatInboundProcessor` does the same, passes `ChatChannelKind.Team` to `dedup.Record`, calls `sender.SendAsync(ChatChannelKind.Team, ...)`, and compares against `ChatSendResult.Sent`. -- [ ] **Step 5: Run the Chat suite** +This is deliberately a minimal adaptation — Task 7 replaces both classes with kind-driven ones. Do not generalise them here; keeping this task's diff to seam changes is what makes it reviewable. -Run: `dotnet test tests/RustPlusBot.Features.Chat.Tests -maxcpucount:1` -Expected: all pass, 2 more than before. +`ChatServiceCollectionExtensions` — the `RelayDedupBuffer` registration stays but its `using` moves to `RustPlusBot.Abstractions.Chat`. -Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` +- [ ] **Step 7: Run the affected suites** + +Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` Expected: `0 Error(s)`. -- [ ] **Step 6: Commit** +Run: `dtk dotnet test tests/RustPlusBot.Features.Chat.Tests tests/RustPlusBot.Features.Connections.Tests -maxcpucount:1` +Expected: all pass. Chat is 2 higher than before; Connections is 1 higher. + +- [ ] **Step 8: Commit** ```bash -git add src/RustPlusBot.Abstractions/Chat src/RustPlusBot.Features.Chat tests/RustPlusBot.Features.Chat.Tests +git add src/RustPlusBot.Abstractions src/RustPlusBot.Features.Connections \ + src/RustPlusBot.Features.Workspace src/RustPlusBot.Features.Chat \ + tests/RustPlusBot.Features.Chat.Tests tests/RustPlusBot.Features.Connections.Tests git commit -m "$(cat <<'EOF' -Share the relay dedup buffer and key it by channel kind +Unify the chat sender, locator and dedup seams across channel kinds -Move RelayDedupBuffer to Abstractions so both chat bridges can share one -instance, and add the channel kind to its key so an identical line relayed to -team and clan chat within the TTL cannot cross-cancel. +Replace ITeamChatSender and ITeamChatChannelLocator with kind-parameterised +IChatSender and IChatChannelLocator, and move RelayDedupBuffer to Abstractions +keyed by channel kind so team and clan echoes cannot cross-cancel. One seam per +concept, so the clan bridge does not need a parallel set. Co-Authored-By: Claude Opus 4.8 (1M context) EOF @@ -2026,138 +2036,163 @@ EOF --- -### Task 7: The clan chat bridge +### Task 7: Generalise the chat bridge to serve both channels + +One bridge, driven by `ChatChannelKind`. `Features.Clans` gets **no** chat bridge — `Features.Chat` owns both, because relaying in-game chat to Discord is one concern regardless of which in-game channel it came from. **Files:** -- Create: `src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj` -- Create: `src/RustPlusBot.Features.Clans/Webhooks/IClanChatWebhookPoster.cs` -- Create: `src/RustPlusBot.Features.Clans/Webhooks/DiscordClanChatWebhookPoster.cs` -- Create: `src/RustPlusBot.Features.Clans/Relaying/ClanChatRelay.cs` -- Create: `src/RustPlusBot.Features.Clans/Inbound/InboundMessage.cs` -- Create: `src/RustPlusBot.Features.Clans/Inbound/InboundOutcome.cs` -- Create: `src/RustPlusBot.Features.Clans/Inbound/ClanChatInboundProcessor.cs` -- Create: `tests/RustPlusBot.Features.Clans.Tests/RustPlusBot.Features.Clans.Tests.csproj` -- Create: `tests/RustPlusBot.Features.Clans.Tests/ClanChatRelayTests.cs` -- Create: `tests/RustPlusBot.Features.Clans.Tests/ClanChatInboundProcessorTests.cs` -- Modify: `RustPlusBot.slnx` +- Rename: `Relaying/TeamChatRelay.cs` → `Relaying/ChatRelay.cs` +- Rename: `Inbound/TeamChatInboundProcessor.cs` → `Inbound/ChatInboundProcessor.cs` +- Rename: `Webhooks/ITeamChatWebhookPoster.cs` → `Webhooks/IChatWebhookPoster.cs`, `DiscordTeamChatWebhookPoster.cs` → `DiscordChatWebhookPoster.cs` +- Create: `src/RustPlusBot.Features.Chat/Relaying/RelayedChatLine.cs` +- Modify: `src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs` +- Modify: `src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs` +- Modify/rename the corresponding test files **Interfaces:** -- Consumes: `ClanMessageReceivedEvent` (T1), `IClanChatSender`/`ClanChatSendResult` (T2), `IClanChatChannelLocator` (T5), `RelayDedupBuffer`/`ChatChannelKind` (T6), `IMuteStore` and `BotTeamChat.Prefix` (existing). -- Produces: `internal sealed class ClanChatRelay` with `Task RelayAsync(ClanMessageReceivedEvent evt, CancellationToken ct)`; `internal sealed class ClanChatInboundProcessor` with `Task ProcessAsync(InboundMessage message, CancellationToken ct)`; `public interface IClanChatWebhookPoster` with `Task PostAsync(ulong channelId, string username, string message, CancellationToken ct)`; `internal sealed record InboundMessage(bool AuthorIsBotOrWebhook, ulong ChannelId, string DisplayName, string Content)`; `internal enum InboundOutcome { Ignored, Sent, Failed }`. +- Consumes: `ChatChannelKind`, `RelayDedupBuffer`, `IChatSender`, `IChatChannelLocator` (Task 6); `ClanMessageReceivedEvent` (Task 1). +- Produces: -`InboundMessage`/`InboundOutcome` are duplicated rather than shared: they are trivial DTOs private to each bridge's Discord adapter, and hoisting them into Abstractions to save eight lines would couple two features that otherwise share nothing. +```csharp +/// One received in-game chat line, normalised across channel kinds. +internal sealed record RelayedChatLine( + ChatChannelKind Kind, ulong GuildId, Guid ServerId, + string SenderName, string Message, bool FromActivePlayer); -- [ ] **Step 1: Scaffold the projects** +internal sealed class ChatRelay +{ + public Task RelayAsync(RelayedChatLine line, CancellationToken cancellationToken); +} -Create `src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj` by copying `src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj` verbatim, then setting its `ProjectReference`s to: `RustPlusBot.Abstractions`, `RustPlusBot.Discord`, `RustPlusBot.Domain`, `RustPlusBot.Features.Connections`, `RustPlusBot.Features.Workspace`, `RustPlusBot.Localization`, `RustPlusBot.Persistence`. +internal sealed class ChatInboundProcessor +{ + public Task ProcessAsync(InboundMessage message, CancellationToken cancellationToken); +} -Create `tests/RustPlusBot.Features.Clans.Tests/RustPlusBot.Features.Clans.Tests.csproj` by copying `tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj` verbatim and pointing its single `ProjectReference` at `RustPlusBot.Features.Clans`. +public interface IChatWebhookPoster +{ + Task PostAsync(ChatChannelKind kind, ulong channelId, string username, string message, CancellationToken cancellationToken); +} +``` -Add an `InternalsVisibleTo` for the test assembly following whatever the Chat project does (either an `AssemblyInfo.cs` or an MSBuild item). +`TeamMessageReceivedEvent` and `ClanMessageReceivedEvent` stay as separate bus types — `Features.Commands` consumes the team one, and merging them would drag a working feature into this refactor. `ChatHostedService` adapts each into `RelayedChatLine` in three lines. -Add both projects to `RustPlusBot.slnx` under the `src/` and `tests/` folders, matching the existing entries' formatting exactly. +**Kind routing on the inbound path:** `ChatInboundProcessor` no longer knows its kind up front. It asks each registered `IChatChannelLocator` to resolve the incoming channel id; the one that matches supplies both the `(guild, server)` and the `Kind`. A channel that no locator claims is ignored. -Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` -Expected: `0 Error(s)` with two new (empty) projects. +**Webhook naming:** `DiscordChatWebhookPoster` derives the name from the kind — `"RustPlusBot TeamChat"` / `"RustPlusBot ClanChat"`. Keep the existing team name byte-for-byte: changing it would orphan every webhook already provisioned in live guilds and silently create duplicates. Cache clients per `(kind, channelId)`. -- [ ] **Step 2: Write the failing tests** +- [ ] **Step 1: Write the failing tests** -`tests/RustPlusBot.Features.Clans.Tests/ClanChatRelayTests.cs` — model on `TeamChatRelayTests.cs`, including its private static `Build(...)` factory returning the SUT plus substitutes: +Rename `TeamChatRelayTests.cs` → `ChatRelayTests.cs` and `TeamChatInboundProcessorTests.cs` → `ChatInboundProcessorTests.cs`, converting each existing test to the new signature and making the kind a parameter. Every behavioural test becomes a `[Theory]` over both kinds, because both must behave identically: ```csharp -[Fact] public async Task Posts_a_normal_message_via_webhook() -[Fact] public async Task Drops_a_bot_prefixed_line_from_the_active_player() -// evt.FromActivePlayer = true, Message = "[R+] pop: 42" => poster never called. -[Fact] public async Task Keeps_a_bot_prefixed_line_from_another_player() -// FromActivePlayer = false => posted. Another player typing "[R+]" is not our echo. -[Fact] public async Task Drops_our_own_echo() -// dedup.Record(ChatChannelKind.Clan, key, text) first => poster never called. -[Fact] public async Task Does_not_consume_a_team_dedup_entry() -// dedup.Record(ChatChannelKind.Team, key, text) => the clan line IS posted. -[Fact] public async Task Drops_a_command_invocation() -// IMuteStore.GetPrefixAsync returns "!"; Message = "!pop" => poster never called. -[Fact] public async Task Ignores_leading_whitespace_when_matching_the_command_prefix() -[Fact] public async Task Does_nothing_when_the_channel_is_not_provisioned() -// locator returns null => poster never called, and the prefix is never queried. + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Posts_a_normal_message_via_webhook(ChatChannelKind kind) + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Drops_a_bot_prefixed_line_from_the_active_player(ChatChannelKind kind) + + [Theory] // ... and likewise for: + // Keeps_a_bot_prefixed_line_from_another_player + // Drops_our_own_echo + // Drops_a_command_invocation + // Ignores_leading_whitespace_when_matching_the_command_prefix + // Does_nothing_when_the_channel_is_not_provisioned ``` -`tests/RustPlusBot.Features.Clans.Tests/ClanChatInboundProcessorTests.cs` — model on `TeamChatInboundProcessorTests.cs`: +Plus these cross-kind facts, which are the whole point of the unification: ```csharp -[Fact] public async Task Ignores_bot_and_webhook_authors() -[Fact] public async Task Ignores_empty_content() -[Fact] public async Task Ignores_a_channel_that_is_not_a_clanchat() -[Fact] public async Task Ignores_a_muted_server_without_recording_a_dedup_entry() -// Assert the sender was never called AND buffer.TryConsume(Clan, key, text) is false. -[Fact] public async Task Records_the_dedup_entry_before_sending() -// Use a sender substitute whose Returns callback asserts the entry is already recorded. -[Fact] public async Task Formats_the_line_with_the_display_name() -// Assert the sent text is exactly "[dave] hello". -[Fact] public async Task Reports_Failed_when_the_send_fails() -[Fact] public async Task Reports_Failed_when_there_is_no_live_socket() -// ClanChatSendResult.NotConnected => InboundOutcome.Failed, so the user sees the ❌ reaction. + [Fact] public async Task A_team_dedup_entry_does_not_suppress_a_clan_line() + [Fact] public async Task A_clan_dedup_entry_does_not_suppress_a_team_line() + [Fact] public async Task Posts_a_clan_line_to_the_clan_channel_not_the_team_channel() + [Fact] public async Task Routes_an_inbound_message_by_which_locator_claims_the_channel() + [Fact] public async Task Ignores_an_inbound_message_in_a_channel_no_locator_claims() + [Fact] public async Task Uses_the_clan_webhook_name_for_clan_lines() ``` -- [ ] **Step 3: Run to verify failure** +And for the inbound processor, converted to `[Theory]` over both kinds: ignores bots/webhooks, ignores empty content, mute gate without recording dedup, records dedup before sending, formats `"[dave] hello"`, `Failed` on a failed send, `Failed` on `NotConnected`. -Run: `dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` -Expected: FAIL — compile errors, none of the types exist. +`ChatHostedServiceTests` gains: -- [ ] **Step 4: Implement the poster** +```csharp + [Fact] public async Task Relays_a_clan_message_event() + [Fact] public async Task Relays_a_team_message_event() +``` -`IClanChatWebhookPoster.cs` and `DiscordClanChatWebhookPoster.cs` — copy `ITeamChatWebhookPoster` / `DiscordTeamChatWebhookPoster` verbatim, renaming the types, changing `WebhookName` to `"RustPlusBot ClanChat"`, and updating the doc comments and the `[LoggerMessage]` text to say "clan chat line". +- [ ] **Step 2: Run to verify failure** -- [ ] **Step 5: Implement the relay** +Run: `dtk dotnet test tests/RustPlusBot.Features.Chat.Tests -maxcpucount:1` +Expected: FAIL — `ChatRelay` and `RelayedChatLine` do not exist. -`src/RustPlusBot.Features.Clans/Relaying/ClanChatRelay.cs` — structurally identical to `TeamChatRelay`: +- [ ] **Step 3: Generalise the poster** + +Rename the interface and class. `PostAsync` gains a leading `ChatChannelKind kind`. Replace the `WebhookName` constant with: ```csharp -using Microsoft.Extensions.DependencyInjection; -using RustPlusBot.Abstractions.Chat; -using RustPlusBot.Abstractions.Events; -using RustPlusBot.Features.Clans.Webhooks; -using RustPlusBot.Features.Connections.Listening; -using RustPlusBot.Features.Workspace.Locating; -using RustPlusBot.Persistence.Commands; + /// + /// Webhook name per channel kind. These strings are load-bearing: the poster re-discovers its + /// webhook by name on restart, so changing one orphans every webhook already created in live + /// guilds and silently creates a duplicate alongside it. + /// + /// The channel kind. + /// The webhook name to find or create. + private static string WebhookNameFor(ChatChannelKind kind) => kind switch + { + ChatChannelKind.Clan => "RustPlusBot ClanChat", + _ => "RustPlusBot TeamChat", + }; +``` -namespace RustPlusBot.Features.Clans.Relaying; +Key the client cache on `(ChatChannelKind Kind, ulong ChannelId)`. -/// -/// Relays one received in-game clan message into its Discord #clanchat channel, dropping our own -/// echoes and command invocations. -/// -/// Resolves the target #clanchat channel. -/// Posts the line via webhook. -/// Tracks lines the bridge relayed into the game so their echoes can be dropped. -/// Opens a scope to read the scoped command prefix. -internal sealed class ClanChatRelay( - IClanChatChannelLocator locator, - IClanChatWebhookPoster poster, +- [ ] **Step 4: Generalise the relay** + +`Relaying/RelayedChatLine.cs` as in the Interfaces block, with full `///` docs. + +`Relaying/ChatRelay.cs` — the existing `TeamChatRelay` body with `evt` replaced by `line`, the locator chosen by `line.Kind`, and the kind threaded into the dedup and poster calls: + +```csharp +internal sealed class ChatRelay( + IEnumerable locators, + IChatWebhookPoster poster, RelayDedupBuffer dedup, IServiceScopeFactory scopeFactory) { - /// Handles one . - /// The received clan message. + private readonly Dictionary _locators = + locators.ToDictionary(l => l.Kind); + + /// Relays one received in-game chat line into its Discord channel. + /// The received line. /// A cancellation token. /// A task that completes when the line has been relayed or dropped. - public async Task RelayAsync(ClanMessageReceivedEvent evt, CancellationToken cancellationToken) + public async Task RelayAsync(RelayedChatLine line, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(evt); + ArgumentNullException.ThrowIfNull(line); - if (evt.FromActivePlayer && evt.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) + if (line.FromActivePlayer && line.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) { - return; // Bot-originated line echoing back; #clanchat carries only human discussion. + return; // Bot-originated line echoing back; the channel carries only human discussion. } - if (evt.FromActivePlayer && - dedup.TryConsume(ChatChannelKind.Clan, (evt.GuildId, evt.ServerId), evt.Message)) + if (line.FromActivePlayer && + dedup.TryConsume(line.Kind, (line.GuildId, line.ServerId), line.Message)) { return; // Our own relayed line echoing back; do not re-post. } + if (!_locators.TryGetValue(line.Kind, out var locator)) + { + return; + } + // The locator is an in-memory cache, so resolve the channel first: unmapped servers exit // before the per-message prefix query below. - var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + var channelId = await locator.GetChannelIdAsync(line.GuildId, line.ServerId, cancellationToken) .ConfigureAwait(false); if (channelId is null) { @@ -2165,64 +2200,114 @@ internal sealed class ClanChatRelay( } // A command invocation gets its reply in game; the bare trigger line is noise in Discord. - var prefix = await GetCommandPrefixAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + var prefix = await GetCommandPrefixAsync(line.GuildId, line.ServerId, cancellationToken) + .ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(prefix) && - evt.Message.TrimStart().StartsWith(prefix, StringComparison.Ordinal)) + line.Message.TrimStart().StartsWith(prefix, StringComparison.Ordinal)) { return; } - await poster.PostAsync(channelId.Value, evt.SenderName, evt.Message, cancellationToken).ConfigureAwait(false); + await poster.PostAsync(line.Kind, channelId.Value, line.SenderName, line.Message, cancellationToken) + .ConfigureAwait(false); } - private async Task GetCommandPrefixAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) - { - // IMuteStore is scoped, so resolve it from a fresh scope rather than capturing it on this singleton. - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) + // GetCommandPrefixAsync unchanged from TeamChatRelay. +} +``` + +`BotTeamChat.Prefix` is the bot's single marker for its own in-game output across both channels, not a team-specific constant. Leave its name alone — renaming it is churn in unrelated files. + +- [ ] **Step 5: Generalise the inbound processor** + +`Inbound/ChatInboundProcessor.cs` — resolve the kind from whichever locator claims the channel: + +```csharp + (ulong GuildId, Guid ServerId)? target = null; + var kind = ChatChannelKind.Team; + foreach (var locator in locators) { - var muteStore = scope.ServiceProvider.GetRequiredService(); - return await muteStore.GetPrefixAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (await locator.ResolveAsync(message.ChannelId, cancellationToken).ConfigureAwait(false) is { } hit) + { + target = hit; + kind = locator.Kind; + break; + } + } + + if (target is not { } t) + { + return InboundOutcome.Ignored; } - } -} ``` -`BotTeamChat.Prefix` is reused deliberately: it is the bot's single marker for its own in-game output, not a team-specific constant. +then the existing mute gate, `dedup.Record(kind, key, text)`, `sender.SendAsync(kind, t.GuildId, t.ServerId, text, cancellationToken)`, and `result == ChatSendResult.Sent ? InboundOutcome.Sent : InboundOutcome.Failed`. -- [ ] **Step 6: Implement the inbound path** +- [ ] **Step 6: Subscribe to both events** -`Inbound/InboundMessage.cs` and `Inbound/InboundOutcome.cs` — copy the Chat equivalents verbatim into the `RustPlusBot.Features.Clans.Inbound` namespace, updating "#teamchat" to "#clanchat" in the docs. +`ChatHostedService` — add a second consumer loop, identical in shape to the first, and adapt both event types: -`Inbound/ClanChatInboundProcessor.cs` — copy `TeamChatInboundProcessor` with these substitutions: `IClanChatChannelLocator`, `IClanChatSender`, `dedup.Record(ChatChannelKind.Clan, key, text)`, and +```csharp + _teamLoop = Task.Run(() => ConsumeTeamMessagesAsync(_cts.Token), CancellationToken.None); + _clanLoop = Task.Run(() => ConsumeClanMessagesAsync(_cts.Token), CancellationToken.None); +``` + +with the bodies mapping to `RelayedChatLine`: ```csharp - return result == ClanChatSendResult.Sent ? InboundOutcome.Sent : InboundOutcome.Failed; + await relay.RelayAsync( + new RelayedChatLine(ChatChannelKind.Team, evt.GuildId, evt.ServerId, evt.SenderName, + evt.Message, evt.FromActivePlayer), cancellationToken).ConfigureAwait(false); ``` -- [ ] **Step 7: Run the tests** +and the clan equivalent with `ChatChannelKind.Clan`. `StopAsync` cancels then awaits **both** loops. Each loop keeps the mandated shape: `OperationCanceledException` swallowed, broad catch with the CA1031 pragma and its own `[LoggerMessage]` partial. -Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` -Expected: PASS, 17 tests. **Confirm the assembly reports 17, not 0** — a build failure in a new project reports zero tests and looks green. +Also record the clan sender's display name for later roster rendering. `IClanStore` is scoped, so resolve it from a fresh scope inside the clan loop: -- [ ] **Step 8: Commit** +```csharp + // Clan members arrive as Steam ids only; chat is where we learn their names. + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + await store.RecordNameAsync(evt.GuildId, evt.ServerId, evt.SenderSteamId, evt.SenderName, + cancellationToken).ConfigureAwait(false); + } +``` + +This makes `Features.Chat` reference `RustPlusBot.Persistence` — it already does, for `IMuteStore`. + +- [ ] **Step 7: Update registrations** + +`ChatServiceCollectionExtensions` — rename the registered types (`IChatWebhookPoster`/`DiscordChatWebhookPoster`, `ChatRelay`, `ChatInboundProcessor`). `RelayDedupBuffer` stays `AddSingleton` here; `AddClans()` will `TryAddSingleton` it. + +Update `ChatRegistrationTests` for the renamed types, and add an assertion that both locator kinds resolve into the relay. + +- [ ] **Step 8: Run the suites** + +Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `0 Error(s)`. + +Run: `dtk dotnet test RustPlusBot.slnx -maxcpucount:1` +Expected: every assembly passes. Read per-assembly counts — `Features.Chat.Tests` should be substantially higher (each converted test now runs twice, once per kind). + +- [ ] **Step 9: Commit** ```bash -git add RustPlusBot.slnx src/RustPlusBot.Features.Clans tests/RustPlusBot.Features.Clans.Tests +git add src/RustPlusBot.Features.Chat tests/RustPlusBot.Features.Chat.Tests git commit -m "$(cat <<'EOF' -Add the clan chat bridge +Drive the chat bridge by channel kind to serve clan chat -Relay in-game clan chat into #clanchat via webhook impersonation and relay -Discord messages back with record-then-send dedup, mirroring the team chat -bridge including its bot-prefix, echo, mute and command-prefix gates. +Generalise the relay, webhook poster and inbound processor over +ChatChannelKind so one bridge serves both team and clan chat, and subscribe the +hosted service to both message events. The inbound path picks its kind from +whichever locator claims the channel. Clan chat senders are recorded as the +name source for the clan roster. Co-Authored-By: Claude Opus 4.8 (1M context) EOF )" ``` - ---- - ### Task 8: The clan snapshot differ A pure function, so this is the highest-value test target in the feature. No I/O, no DI. @@ -2773,7 +2858,7 @@ EOF **Interfaces:** - Consumes: `ClanComponentIds` (T9), `IClanStore` (T4), `IRustServerConnection.SetClanMotdAsync` (T2). -- Produces: `internal interface IClanMotdWriter { Task SetAsync(ulong guildId, Guid serverId, ulong actorSteamId, string motd, CancellationToken cancellationToken); }` and `internal enum ClanMotdWriteResult { Ok, NotConnected, NotPermitted, Failed }`. +- Produces: `internal interface IClanMotdWriter { Task SetAsync(ulong guildId, Guid serverId, ulong actorSteamId, string motd, CancellationToken cancellationToken); }` and `internal enum ClanMotdWriteResult { Ok, NotPermitted, Failed }`. Interaction modules are Discord.Net-bound and effectively untestable in this codebase (no other feature unit-tests one), so all decision logic lives in `ClanMotdWriter`, which IS tested. The module only marshals the interaction. @@ -2828,14 +2913,11 @@ internal enum ClanMotdWriteResult /// The MOTD was set. Ok = 0, - /// There is no live socket for that server. - NotConnected = 1, - /// The acting player's clan role does not allow setting the MOTD. - NotPermitted = 2, + NotPermitted = 1, - /// The write was attempted and failed. - Failed = 3, + /// The write did not succeed: no live socket, a rejected write, or blank input. + Failed = 2, } /// @@ -2884,7 +2966,7 @@ internal sealed class ClanMotdWriter(IClanStore store, IRustServerQuery query) : } ``` -`NotConnected` is currently unreachable because `IRustServerQuery.SetClanMotdAsync` collapses "no socket" into false. That is deliberate: the user-facing message for both is the same, and widening the query seam's return type for a distinction nothing acts on is not worth it. Keep the enum member — it documents the state and costs nothing. +There is deliberately no `NotConnected` member. `IRustServerQuery.SetClanMotdAsync` collapses "no live socket" into `false`, so such a member would be unreachable, and the user-facing message is the same either way. Do not add one. - [ ] **Step 4: Implement the modal and module** @@ -3008,12 +3090,11 @@ Reconcile is called only on a **transition** (none→clan, clan→none), never o [Fact] public void Resolves_every_registered_clan_service() // Real ServiceCollection + the substitutes AddClans needs, then // BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }) and resolve each. -[Fact] public void Shares_one_dedup_buffer_between_the_relay_and_the_processor() -[Fact] public void Shares_the_dedup_buffer_with_the_team_chat_bridge() -// Call AddChat() and AddClans() on one collection; assert a single RelayDedupBuffer instance. [Fact] public void Registers_the_clan_capability_provider() // Resolve IEnumerable and assert one has Capability == "clan". [Fact] public void Registers_the_three_clan_message_renderers() +[Fact] public void Does_not_register_a_second_chat_bridge() +// Features.Chat owns both bridges; assert AddClans registers no ChatRelay/IChatWebhookPoster. ``` - [ ] **Step 2: Run to verify failure** @@ -3110,13 +3191,11 @@ internal sealed class ClanCapabilityProvider(IServiceScopeFactory scopeFactory) - [ ] **Step 7: Implement the hosted service** -`Hosting/ClansHostedService.cs` — copy `ChatHostedService` and extend it to **two** consumer loops plus the Discord listener: +`Hosting/ClansHostedService.cs` — a **single** consumer loop. Clan chat is not handled here at all: `Features.Chat` owns both bridges after Task 7, and clan chat senders are already recorded as names there. -- loop 1: `SubscribeAsync` → `relay.RelayAsync`, and also record the sender's name via `IClanStore.RecordNameAsync` (this is the main name-harvesting path). -- loop 2: `SubscribeAsync` → `stateService.ApplyAsync`. -- `MessageReceived` → `ClanChatInboundProcessor`, ❌ on `InboundOutcome.Failed`. +- `SubscribeAsync` → `stateService.ApplyAsync`. -Both loops follow the mandated shape verbatim: `Task.Run` in `StartAsync`, `await foreach`, `OperationCanceledException` swallowed, broad catch with the CA1031 pragma and a `[LoggerMessage]` partial. `StopAsync` cancels then awaits **both** loop tasks. +The loop follows the mandated shape verbatim: `Task.Run` in `StartAsync`, `await foreach`, `OperationCanceledException` swallowed, broad catch with the CA1031 pragma and a `[LoggerMessage]` partial. `StopAsync` cancels then awaits the loop task. There is no `MessageReceived` hook — nothing in `#claninfo` is read back. - [ ] **Step 8: Implement `AddClans()`** @@ -3125,15 +3204,9 @@ public static IServiceCollection AddClans(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - // Shared with AddChat: TryAdd so the two bridges get one buffer in either composition order. - services.TryAddSingleton(); - services.AddRustPlusBotLocalization(); - services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -3157,7 +3230,7 @@ public static IServiceCollection AddClans(this IServiceCollection services) - [ ] **Step 9: Run the tests** Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` -Expected: PASS, 76 total (63 + 8 state + 5 registration). +Expected: PASS, 74 total (63 + 8 state + 3 registration). Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` Expected: `0 Error(s)`. @@ -3266,7 +3339,7 @@ Expected: the host reaches Discord login. A `ValidateOnStart` or DI resolution f Run: `dotnet test RustPlusBot.slnx -maxcpucount:1` -Expected: every assembly passes. **Read the per-assembly counts.** `RustPlusBot.Features.Clans.Tests` must report **76**, not 0. An assembly reporting 0 means it failed to build and its tests silently did not run. +Expected: every assembly passes. **Read the per-assembly counts.** `RustPlusBot.Features.Clans.Tests` must report **74**, not 0. An assembly reporting 0 means it failed to build and its tests silently did not run. Record the actual per-assembly numbers in the commit message or PR body — not "all tests pass". diff --git a/docs/superpowers/specs/2026-07-21-clan-support-design.md b/docs/superpowers/specs/2026-07-21-clan-support-design.md index ba902cba..5726b00e 100644 --- a/docs/superpowers/specs/2026-07-21-clan-support-design.md +++ b/docs/superpowers/specs/2026-07-21-clan-support-design.md @@ -13,7 +13,7 @@ has no awareness of clans at all. We want: 1. Detection of whether the paired player on a given Rust server is in a clan. -2. A `#clanchat` channel bridging clan chat, mirroring the existing `#teamchat` bridge. +2. A `#clanchat` channel bridging clan chat, served by the same bridge as `#teamchat`. 3. A `#claninfo` channel presenting clan data — overview, roster, invites — plus a live feed of clan changes. 4. Both channels present **only** while a clan exists, and removed when it does not. @@ -98,7 +98,7 @@ Task SendClanMessageAsync(string message, CancellationToken ct); Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken ct); event EventHandler? ClanMessageReceived; -event EventHandler? ClanChanged; +event EventHandler? ClanChanged; ``` `GetClanChatAsync` (history) is **not** added: the bridge is live-only, matching teamchat, @@ -125,8 +125,8 @@ into a single `null` would let a socket hiccup delete a user's channels. - publishes `ClanMessageReceivedEvent(GuildId, ServerId, SteamId, Name, Message, Time, FromActivePlayer)` and `ClanStateChangedEvent(GuildId, ServerId, ClanSnapshot?, ClanProbeStatus)` on `IEventBus` -- implements `IClanChatSender` returning `Sent` / `NotConnected` / `Failed`, mirroring - `ITeamChatSender` +- implements the unified `IChatSender`, which takes a `ChatChannelKind` and returns + `Sent` / `NotConnected` / `Failed`, replacing the team-specific `ITeamChatSender` `RejectedConnection` gets the corresponding no-op implementations. @@ -192,11 +192,21 @@ storagemonitors 8. A deliberate clone of the teamchat bridge, so it inherits its already-proven edge-case handling. Files mirror `Features.Chat` one-for-one. -**Game → Discord.** `ClansHostedService` consumes `ClanMessageReceivedEvent` in the -mandated `AlarmsHostedService` loop shape → `ClanChatRelay` → `IClanChatWebhookPoster` / -`DiscordClanChatWebhookPoster` (webhook named `"RustPlusBot ClanChat"`, re-discovered by -name on restart, `ConcurrentDictionary`-cached, `username:` impersonation, -`AllowedMentions.None`). +**One bridge, not two.** Rather than cloning the team bridge, `Features.Chat` is +generalised over a `ChatChannelKind { Team, Clan }` discriminator and serves both channels +from one implementation: one `ChatRelay`, one `ChatInboundProcessor`, one +`DiscordChatWebhookPoster` (webhook name selected by kind), and one `IChatSender` / +`IChatChannelLocator` seam replacing the team-specific pair. `Features.Clans` therefore +contains no chat code at all — it owns clan state, the capability, the embeds, the feed and +the MOTD write. This costs a refactor of the working team bridge, covered by its existing +tests re-run against both kinds, and buys a single place to fix any relay bug. + +**Game → Discord.** `ChatHostedService` consumes both `TeamMessageReceivedEvent` and +`ClanMessageReceivedEvent` in the mandated loop shape, normalises each into a +`RelayedChatLine` carrying its kind, and hands it to `ChatRelay` → `IChatWebhookPoster` +(webhook `"RustPlusBot ClanChat"` for clan lines, the unchanged `"RustPlusBot TeamChat"` +for team lines, re-discovered by name on restart, cached per (kind, channel), `username:` +impersonation, `AllowedMentions.None`). `ClanChatRelay` drops, in order: @@ -206,11 +216,12 @@ name on restart, `ConcurrentDictionary`-cached, `username:` impersonation, then resolves the channel via `IClanChatChannelLocator` and posts. -**Discord → game.** `ClanChatInboundProcessor` ignores bot and webhook authors and empty -content, reverse-resolves `(guild, server)` from the channel id, applies the mute gate, +**Discord → game.** `ChatInboundProcessor` ignores bot and webhook authors and empty +content, then asks each registered `IChatChannelLocator` to claim the channel id — the one +that matches supplies both `(guild, server)` and the kind. It applies the mute gate, formats `"[{DisplayName}] {Content}"` with `CultureInfo.InvariantCulture`, **records in the dedup buffer before sending** (the in-game echo can beat the send response), then calls -`IClanChatSender.SendAsync`. A `Failed` outcome reacts ❌ on the Discord message. +`IChatSender.SendAsync` with the resolved kind. A `Failed` outcome reacts ❌ on the Discord message. **Dedup buffer.** `RelayDedupBuffer` is currently keyed `(ulong Guild, Guid Server)`. It gains a `ChatChannelKind { Team, Clan }` discriminator in the key so a clan echo cannot @@ -218,9 +229,9 @@ cancel an identical team-chat line sent in the same 15 s window. The buffer stay shared singleton; the registration test asserting relay and processor share one instance is extended to cover the clan pair. -**Locator.** `IClanChatChannelLocator` / `ClanChatChannelLocator` — a subclass of -`CachingChannelLocator` over `WorkspaceChannelKeys.ServerClanChat`, identical in shape to -`TeamChatChannelLocator`, including the reverse `ResolveAsync(channelId)` lookup. +**Locator.** `ClanChatChannelLocator` — a `CachingChannelLocator` subclass over +`WorkspaceChannelKeys.ServerClanChat` implementing the shared `IChatChannelLocator` +alongside `TeamChatChannelLocator`, including the reverse `ResolveAsync(channelId)` lookup. ## `#claninfo` From 9d43ef403017fafbc717451393523888f7978fac Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 22:38:33 +0200 Subject: [PATCH 04/39] Add clan snapshot records and bus events Introduce the RustPlusApi-free clan snapshot shapes, the three-state clan probe result, and the two event-bus events the clan feature consumes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Connections/ClanProbeResult.cs | 35 ++++++++ .../Connections/ClanSnapshot.cs | 80 +++++++++++++++++++ .../Events/ClanMessageReceivedEvent.cs | 16 ++++ .../Events/ClanStateChangedEvent.cs | 18 +++++ 4 files changed, 149 insertions(+) create mode 100644 src/RustPlusBot.Abstractions/Connections/ClanProbeResult.cs create mode 100644 src/RustPlusBot.Abstractions/Connections/ClanSnapshot.cs create mode 100644 src/RustPlusBot.Abstractions/Events/ClanMessageReceivedEvent.cs create mode 100644 src/RustPlusBot.Abstractions/Events/ClanStateChangedEvent.cs diff --git a/src/RustPlusBot.Abstractions/Connections/ClanProbeResult.cs b/src/RustPlusBot.Abstractions/Connections/ClanProbeResult.cs new file mode 100644 index 00000000..a2c16ee7 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/ClanProbeResult.cs @@ -0,0 +1,35 @@ +namespace RustPlusBot.Abstractions.Connections; + +/// The outcome of asking a live socket for its clan snapshot. +public enum ClanProbeStatus +{ + /// The player is in a clan and the snapshot is populated. + HasClan = 0, + + /// The server answered definitively that the player is in no clan. + NoClan = 1, + + /// The request failed or timed out; the previous known state must be preserved. + Unavailable = 2, +} + +/// +/// A clan probe outcome. and +/// are deliberately distinct: collapsing them into a +/// single null snapshot would let a transient socket failure tear down a guild's clan channels. +/// +/// What the server said. +/// The snapshot; non-null if and only if is . +public sealed record ClanProbeResult(ClanProbeStatus Status, ClanSnapshot? Snapshot) +{ + /// A probe that definitively reported no clan. + public static ClanProbeResult NoClan { get; } = new(ClanProbeStatus.NoClan, null); + + /// A probe that failed; the caller must preserve the last known state. + public static ClanProbeResult Unavailable { get; } = new(ClanProbeStatus.Unavailable, null); + + /// Creates a successful probe carrying . + /// The clan snapshot returned by the server. + /// A result. + public static ClanProbeResult From(ClanSnapshot snapshot) => new(ClanProbeStatus.HasClan, snapshot); +} diff --git a/src/RustPlusBot.Abstractions/Connections/ClanSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/ClanSnapshot.cs new file mode 100644 index 00000000..3ab0dbbd --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/ClanSnapshot.cs @@ -0,0 +1,80 @@ +namespace RustPlusBot.Abstractions.Connections; + +/// A permission role within a clan. +/// Unique id of the role within the clan. +/// Rank order; LOWER values are HIGHER ranks (0 is the leader). +/// Display name of the role. +/// True when holders may set the clan MOTD. +/// True when holders may set the clan logo. +/// True when holders may invite players. +/// True when holders may kick members. +/// True when holders may promote members. +/// True when holders may demote members. +/// True when holders may set notes on members. +/// True when holders may view clan audit logs. +/// True when holders may view clan score events. +public sealed record ClanRoleSnapshot( + int RoleId, + int Rank, + string Name, + bool CanSetMotd, + bool CanSetLogo, + bool CanInvite, + bool CanKick, + bool CanPromote, + bool CanDemote, + bool CanSetPlayerNotes, + bool CanAccessLogs, + bool CanAccessScoreEvents); + +/// A member of a clan. +/// Steam64 id of the member. +/// Id of the role assigned to this member. +/// When the member joined the clan (UTC). +/// When the member was last seen online (UTC). +/// Officer notes attached to this member, or null. +/// True when the member is currently online. The API reports this as nullable; null is mapped to false. +public sealed record ClanMemberSnapshot( + ulong SteamId, + int RoleId, + DateTimeOffset Joined, + DateTimeOffset LastSeen, + string? Notes, + bool Online); + +/// A pending invitation to join a clan. +/// Steam64 id of the invited player. +/// Steam64 id of the member who sent the invitation. +/// When the invitation was created (UTC). +public sealed record ClanInviteSnapshot(ulong SteamId, ulong Recruiter, DateTimeOffset Timestamp); + +/// A full clan snapshot, decoupled from RustPlusApi types. +/// Unique identifier of the clan. +/// Display name of the clan. +/// When the clan was created (UTC). +/// Steam64 id of the clan creator. +/// Message of the day, or null when unset. +/// When the MOTD was last changed (UTC), or null. +/// Steam64 id of the player who last changed the MOTD, or null. +/// Stable hash of the clan logo bytes, or null when no logo is set. The bytes themselves are not carried: nothing renders them. +/// Clan colour as a packed ARGB integer, or null. +/// Maximum members allowed, or null when uncapped. +/// Clan score, or null when the server does not report one. +/// Roles defined in this clan. +/// Current members of the clan. +/// Pending invitations to the clan. +public sealed record ClanSnapshot( + long ClanId, + string Name, + DateTimeOffset Created, + ulong Creator, + string? Motd, + DateTimeOffset? MotdTimestamp, + ulong? MotdAuthor, + string? LogoHash, + int? Color, + int? MaxMemberCount, + long? Score, + IReadOnlyList Roles, + IReadOnlyList Members, + IReadOnlyList Invites); diff --git a/src/RustPlusBot.Abstractions/Events/ClanMessageReceivedEvent.cs b/src/RustPlusBot.Abstractions/Events/ClanMessageReceivedEvent.cs new file mode 100644 index 00000000..12ebd462 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/ClanMessageReceivedEvent.cs @@ -0,0 +1,16 @@ +namespace RustPlusBot.Abstractions.Events; + +/// Published when an in-game clan chat line is received, so the bridge can relay it to Discord. +/// The owning guild snowflake. +/// The server whose socket received the line. +/// The Steam64 id of the in-game sender. +/// The in-game display name of the sender. +/// The message text. +/// True when the sender is the bot's active player (used to drop relay echoes). +public sealed record ClanMessageReceivedEvent( + ulong GuildId, + Guid ServerId, + ulong SenderSteamId, + string SenderName, + string Message, + bool FromActivePlayer); diff --git a/src/RustPlusBot.Abstractions/Events/ClanStateChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/ClanStateChangedEvent.cs new file mode 100644 index 00000000..1eebe828 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/ClanStateChangedEvent.cs @@ -0,0 +1,18 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Abstractions.Events; + +/// +/// Published when a server's clan snapshot may have changed — on connect (probe) and on every +/// in-game clan change. Carries the probe status so consumers can tell "definitely no clan" +/// apart from "could not ask". +/// +/// The owning guild snowflake. +/// The server the snapshot belongs to. +/// Whether the player has a clan, has none, or could not be asked. +/// The new snapshot; non-null if and only if is . +public sealed record ClanStateChangedEvent( + ulong GuildId, + Guid ServerId, + ClanProbeStatus Status, + ClanSnapshot? Snapshot); From 9c6779e01d9ae5ca120e97812f4df633c6fab573 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 22:50:13 +0200 Subject: [PATCH 05/39] Map clan responses and extend the connection seam Add ClanMapping, which is the only place clan protobuf models and RustPlusErrorCode are interpreted, and extend IRustServerConnection with the clan probe, clan send, MOTD write, and the two clan events. no_clan maps to a definitive NoClan; every other failure degrades to Unavailable so a transient fault cannot look like a departed clan. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Listening/ClanChatLine.cs | 8 ++ .../Listening/ClanMapping.cs | 89 ++++++++++++ .../Listening/IRustServerConnection.cs | 29 ++++ .../Listening/RustPlusSocketSource.cs | 91 ++++++++++++ .../ClanMappingTests.cs | 136 ++++++++++++++++++ .../Fakes/FakeRustSocketSource.cs | 32 +++++ 6 files changed, 385 insertions(+) create mode 100644 src/RustPlusBot.Features.Connections/Listening/ClanChatLine.cs create mode 100644 src/RustPlusBot.Features.Connections/Listening/ClanMapping.cs create mode 100644 tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs diff --git a/src/RustPlusBot.Features.Connections/Listening/ClanChatLine.cs b/src/RustPlusBot.Features.Connections/Listening/ClanChatLine.cs new file mode 100644 index 00000000..0cdf841a --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/ClanChatLine.cs @@ -0,0 +1,8 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// A raw clan chat line as received from a socket (before active-player classification). +/// The Steam64 id of the sender. +/// The in-game display name of the sender. +/// The message text. +/// When the line was sent (UTC). +internal sealed record ClanChatLine(ulong SteamId, string Name, string Message, DateTimeOffset Time); diff --git a/src/RustPlusBot.Features.Connections/Listening/ClanMapping.cs b/src/RustPlusBot.Features.Connections/Listening/ClanMapping.cs new file mode 100644 index 00000000..e6191071 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/ClanMapping.cs @@ -0,0 +1,89 @@ +using System.Security.Cryptography; +using RustPlusApi.Data; +using RustPlusApi.Data.Clans; +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Features.Connections.Listening; + +/// +/// Maps RustPlusApi clan types onto the bot's RustPlusApi-free snapshots. The ONLY place clan +/// protobuf models and are interpreted. +/// +internal static class ClanMapping +{ + /// + /// Classifies a clan-info response. no_clan is a definitive negative; every other + /// failure — including a success carrying no payload — is , + /// so a transient fault never looks like "the player left their clan". + /// + /// Whether the Rust+ response succeeded. + /// The error code if the response failed, or null. + /// The response payload, or null. + /// The classified probe result. + public static ClanProbeResult FromResponse(bool isSuccess, RustPlusErrorCode? errorCode, ClanInfo? data) + { + if (isSuccess) + { + return data is null ? ClanProbeResult.Unavailable : ClanProbeResult.From(ToSnapshot(data)); + } + + return errorCode == RustPlusErrorCode.NoClan ? ClanProbeResult.NoClan : ClanProbeResult.Unavailable; + } + + /// Converts a RustPlusApi clan payload into the bot's snapshot shape. + /// The clan payload to convert. + /// The equivalent . + public static ClanSnapshot ToSnapshot(ClanInfo info) + { + ArgumentNullException.ThrowIfNull(info); + + return new ClanSnapshot( + info.ClanId, + info.Name ?? string.Empty, + Utc(info.Created), + info.Creator, + info.Motd, + info.MotdTimestamp is { } motdAt ? Utc(motdAt) : null, + info.MotdAuthor, + HashLogo(info.Logo), + info.Color, + info.MaxMemberCount, + info.Score, + [ + .. (info.Roles ?? []).Select(r => new ClanRoleSnapshot( + r.RoleId, r.Rank, r.Name ?? string.Empty, r.CanSetMotd, r.CanSetLogo, r.CanInvite, + r.CanKick, r.CanPromote, r.CanDemote, r.CanSetPlayerNotes, r.CanAccessLogs, + r.CanAccessScoreEvents)), + ], + [ + .. (info.Members ?? []).Select(m => new ClanMemberSnapshot( + m.SteamId, m.RoleId, Utc(m.Joined), Utc(m.LastSeen), m.Notes, m.Online ?? false)), + ], + [ + .. (info.Invites ?? []).Select(i => new ClanInviteSnapshot( + i.SteamId, i.Recruiter, Utc(i.Timestamp))), + ]); + } + + /// + /// Hashes the clan logo bytes so a change can be detected without carrying or storing the image. + /// Not a security boundary — SHA-256 is used purely as a stable content fingerprint. + /// + /// The raw logo bytes, or null. + /// A lowercase hex digest, or null when there is no logo. + public static string? HashLogo(byte[]? logo) + { + if (logo is null || logo.Length == 0) + { + return null; + } + + return Convert.ToHexString(SHA256.HashData(logo)).ToLowerInvariant(); + } + + /// Reinterprets an API timestamp as UTC (the API documents all clan timestamps as UTC). + /// The timestamp to normalise. + /// The equivalent at zero offset. + private static DateTimeOffset Utc(DateTime value) => + new(DateTime.SpecifyKind(value, DateTimeKind.Utc), TimeSpan.Zero); +} diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index dac5adbb..ad5e8454 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -42,6 +42,26 @@ internal interface IRustServerConnection : IAsyncDisposable /// Unlike the probe methods, this surfaces send failures to the caller (the supervisor maps them to a failed send result). Task SendTeamMessageAsync(string message, CancellationToken cancellationToken); + /// Probes the authenticated player's clan, distinguishing "no clan" from "could not ask". + /// How long to wait for the response. + /// A cancellation token. + /// The classified probe result. + Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken); + + /// Sends a message to in-game clan chat. + /// The message text to send. + /// A cancellation token. + /// A task that completes when the send has been issued. + /// Like , this surfaces failures to the caller. + Task SendClanMessageAsync(string message, CancellationToken cancellationToken); + + /// Sets the clan message of the day; returns true on success. + /// The new message of the day. + /// How long to wait for the response. + /// A cancellation token. + /// True if the MOTD was set; false on failure/timeout. + Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken); + /// Promotes a team member to team leader; returns true on success. /// Steam64 id of the member to promote. /// How long to wait for the response. @@ -128,6 +148,15 @@ Task> GetMonumentsAsync(TimeSpan timeout, /// Raised for every in-game team chat line received on this socket. event EventHandler? TeamMessageReceived; + /// Raised for every in-game clan chat line received on this socket. + event EventHandler? ClanMessageReceived; + + /// + /// Raised when the clan snapshot changes in game. A dissolved or departed clan arrives as + /// — a definitive signal, never . + /// + event EventHandler? ClanChanged; + /// Raised when a managed smart device's state changes in-game; carries the entity id and new state. event EventHandler? SmartDeviceTriggered; diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index d61513fb..c735ba0c 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -1,6 +1,7 @@ using System.Globalization; using Microsoft.Extensions.Logging; using RustPlusApi; +using RustPlusApi.Data.Events; using RustPlusBot.Abstractions.Connections; namespace RustPlusBot.Features.Connections.Listening; @@ -46,6 +47,15 @@ public Task GetInfoAsync(TimeSpan timeout, CancellationToken ca public Task SendTeamMessageAsync(string message, CancellationToken cancellationToken) => Task.CompletedTask; + public Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(ClanProbeResult.Unavailable); + + public Task SendClanMessageAsync(string message, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(false); + public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(false); @@ -97,6 +107,18 @@ public event EventHandler? TeamMessageReceived remove { _ = value; } } + public event EventHandler? ClanMessageReceived + { + add { _ = value; } + remove { _ = value; } + } + + public event EventHandler? ClanChanged + { + add { _ = value; } + remove { _ = value; } + } + public event EventHandler? SmartDeviceTriggered { add { _ = value; } @@ -134,6 +156,8 @@ public RustPlusServerConnection(string ip, int port, ulong steamId, int playerTo _rustPlus.OnTeamChatReceived += OnTeamChatReceived; _rustPlus.OnSmartDeviceTriggered += OnSmartDeviceTriggered; _rustPlus.OnStorageMonitorTriggered += OnStorageMonitorTriggered; + _rustPlus.OnClanChatReceived += OnClanChatReceived; + _rustPlus.OnClanChanged += OnClanChanged; } public async Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) @@ -326,6 +350,10 @@ public async Task GetInfoAsync(TimeSpan timeout, CancellationTo public event EventHandler? TeamMessageReceived; + public event EventHandler? ClanMessageReceived; + + public event EventHandler? ClanChanged; + public async Task SendTeamMessageAsync(string message, CancellationToken cancellationToken) { // CONFIRMED: SendTeamMessageAsync(string, CancellationToken) in 2.0.0-beta.1 returns Task>. @@ -334,6 +362,58 @@ public async Task SendTeamMessageAsync(string message, CancellationToken cancell await _rustPlus.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); } + public async Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + // CONFIRMED (2.0.0-beta.4): GetClanInfoAsync returns Task>, exposing + // IsSuccess, Data, and Error?.Code as the response accessors. + var response = await _rustPlus.GetClanInfoAsync(timeoutCts.Token).ConfigureAwait(false); + return ClanMapping.FromResponse(response.IsSuccess, response.Error?.Code, response.Data); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return ClanProbeResult.Unavailable; + } +#pragma warning disable CA1031 // Broad catch: a failed clan probe must degrade to Unavailable, never crash the caller. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogQueryFailed(_logger, ex); + return ClanProbeResult.Unavailable; + } + } + + public async Task SendClanMessageAsync(string message, CancellationToken cancellationToken) + { + // Intentional: send failures propagate to the caller (the supervisor classifies them). + await _rustPlus.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false); + } + + public async Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + var response = await _rustPlus.SetClanMotdAsync(motd, timeoutCts.Token).ConfigureAwait(false); + return response.IsSuccess; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } +#pragma warning disable CA1031 // Broad catch: a failed MOTD write is reported to the user, not thrown. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogQueryFailed(_logger, ex); + return false; + } + } + public async Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) @@ -660,6 +740,8 @@ public async ValueTask DisposeAsync() _rustPlus.OnTeamChatReceived -= OnTeamChatReceived; _rustPlus.OnSmartDeviceTriggered -= OnSmartDeviceTriggered; _rustPlus.OnStorageMonitorTriggered -= OnStorageMonitorTriggered; + _rustPlus.OnClanChatReceived -= OnClanChatReceived; + _rustPlus.OnClanChanged -= OnClanChanged; try { // CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1. @@ -720,6 +802,15 @@ private static void AddMarkers( private void OnTeamChatReceived(object? sender, RustPlusApi.Data.Events.TeamMessageEventArg e) => TeamMessageReceived?.Invoke(this, new TeamChatLine(e.SteamId, e.Name, e.Message)); + private void OnClanChatReceived(object? sender, ClanMessageEventArg e) => + ClanMessageReceived?.Invoke(this, + new ClanChatLine(e.SteamId, e.Name ?? string.Empty, e.Message ?? string.Empty, + new DateTimeOffset(DateTime.SpecifyKind(e.Time, DateTimeKind.Utc), TimeSpan.Zero))); + + private void OnClanChanged(object? sender, ClanChangedEventArg e) => + ClanChanged?.Invoke(this, + e.ClanInfo is { } info ? ClanProbeResult.From(ClanMapping.ToSnapshot(info)) : ClanProbeResult.NoClan); + [LoggerMessage(Level = LogLevel.Warning, Message = "Rust+ socket connect failed.")] private static partial void LogConnectFailed(ILogger logger, Exception ex); diff --git a/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs new file mode 100644 index 00000000..f165d42e --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs @@ -0,0 +1,136 @@ +using RustPlusApi.Data; +using RustPlusApi.Data.Clans; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class ClanMappingTests +{ + [Fact] + public void Maps_no_clan_error_to_NoClan() + { + var result = ClanMapping.FromResponse(false, RustPlusErrorCode.NoClan, null); + + Assert.Equal(ClanProbeStatus.NoClan, result.Status); + Assert.Null(result.Snapshot); + } + + [Fact] + public void Maps_any_other_error_to_Unavailable() + { + var result = ClanMapping.FromResponse(false, RustPlusErrorCode.NotFound, null); + + Assert.Equal(ClanProbeStatus.Unavailable, result.Status); + } + + [Fact] + public void Maps_a_missing_error_code_to_Unavailable() + { + var result = ClanMapping.FromResponse(false, null, null); + + Assert.Equal(ClanProbeStatus.Unavailable, result.Status); + } + + [Fact] + public void Maps_success_with_null_payload_to_Unavailable() + { + // A success carrying no data is not evidence of "no clan"; preserve the last known state. + var result = ClanMapping.FromResponse(true, null, null); + + Assert.Equal(ClanProbeStatus.Unavailable, result.Status); + } + + [Fact] + public void Maps_a_populated_clan_to_a_snapshot() + { + var info = new ClanInfo + { + ClanId = 42, + Name = "Wolves", + Created = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + Creator = 7UL, + Motd = "hold the line", + MotdTimestamp = new DateTime(2026, 2, 2, 0, 0, 0, DateTimeKind.Utc), + MotdAuthor = 9UL, + Logo = [1, 2, 3], + Color = 255, + MaxMemberCount = 8, + Score = 1234, + Roles = [new ClanRole { RoleId = 1, Rank = 0, Name = "Leader", CanSetMotd = true }], + Members = + [ + new ClanMember + { + SteamId = 7UL, + RoleId = 1, + Joined = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + LastSeen = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), + Notes = "founder", + Online = true, + }, + ], + Invites = [new ClanInvite { SteamId = 11UL, Recruiter = 7UL, Timestamp = new DateTime(2026, 3, 2, 0, 0, 0, DateTimeKind.Utc) }], + }; + + var result = ClanMapping.FromResponse(true, null, info); + + Assert.Equal(ClanProbeStatus.HasClan, result.Status); + var snapshot = Assert.IsType(result.Snapshot); + Assert.Equal(42L, snapshot.ClanId); + Assert.Equal("Wolves", snapshot.Name); + Assert.Equal(7UL, snapshot.Creator); + Assert.Equal(1234L, snapshot.Score); + Assert.Equal(8, snapshot.MaxMemberCount); + Assert.Single(snapshot.Roles); + Assert.True(snapshot.Roles[0].CanSetMotd); + Assert.Single(snapshot.Members); + Assert.True(snapshot.Members[0].Online); + Assert.Equal("founder", snapshot.Members[0].Notes); + Assert.Single(snapshot.Invites); + Assert.Equal(11UL, snapshot.Invites[0].SteamId); + Assert.NotNull(snapshot.LogoHash); + } + + [Fact] + public void Treats_a_null_Online_flag_as_offline() + { + // ClanMember.Online is bool? in the API; null must not be rendered as online. + var info = NewClan(members: [new ClanMember { SteamId = 7UL, RoleId = 1, Online = null }]); + + var result = ClanMapping.FromResponse(true, null, info); + + Assert.False(result.Snapshot!.Members[0].Online); + } + + [Fact] + public void Treats_timestamps_as_utc() + { + var info = NewClan() with { Created = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Unspecified) }; + + var result = ClanMapping.FromResponse(true, null, info); + + Assert.Equal(TimeSpan.Zero, result.Snapshot!.Created.Offset); + Assert.Equal(new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), result.Snapshot.Created.UtcDateTime); + } + + [Fact] + public void Hashes_a_missing_logo_to_null_and_equal_logos_to_equal_hashes() + { + Assert.Null(ClanMapping.HashLogo(null)); + Assert.Null(ClanMapping.HashLogo([])); + Assert.Equal(ClanMapping.HashLogo([1, 2, 3]), ClanMapping.HashLogo([1, 2, 3])); + Assert.NotEqual(ClanMapping.HashLogo([1, 2, 3]), ClanMapping.HashLogo([3, 2, 1])); + } + + private static ClanInfo NewClan(IEnumerable? members = null) => + new() + { + ClanId = 1, + Name = "C", + Creator = 1UL, + Roles = [], + Members = members ?? [], + Invites = [], + }; +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 66658535..f2f5e5de 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -231,6 +231,12 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// Raised when a team chat message arrives on this connection. public event EventHandler? TeamMessageReceived; + /// Raised when a clan chat message arrives on this connection. + public event EventHandler? ClanMessageReceived; + + /// Raised when the clan snapshot changes on this connection. + public event EventHandler? ClanChanged; + /// Raised by . public event EventHandler? SmartDeviceTriggered; @@ -258,6 +264,24 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT return Task.CompletedTask; } + /// The probe result this fake returns; defaults to no clan. + public ClanProbeResult ClanProbe { get; set; } = ClanProbeResult.NoClan; + + /// Messages sent to in-game clan chat through this fake. + public List SentClanMessages { get; } = []; + + public Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(ClanProbe); + + public Task SendClanMessageAsync(string message, CancellationToken cancellationToken) + { + SentClanMessages.Add(message); + return Task.CompletedTask; + } + + public Task SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(true); + #pragma warning disable RCS1163 // Unused parameters for fake implementation public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) #pragma warning restore RCS1163 @@ -370,6 +394,14 @@ public Task> GetMonumentsAsync(TimeSpan timeout, /// The team chat line to raise. public void RaiseTeamMessage(TeamChatLine line) => TeamMessageReceived?.Invoke(this, line); + /// Raises to simulate an inbound clan chat line. + /// The clan chat line to raise. + public void RaiseClanMessage(ClanChatLine line) => ClanMessageReceived?.Invoke(this, line); + + /// Raises to simulate a clan snapshot change. + /// The probe result to raise. + public void RaiseClanChanged(ClanProbeResult result) => ClanChanged?.Invoke(this, result); + /// Simulates an in-game smart-device state change. /// The smart-device entity id to raise the event for. /// The current active state carried on the trigger arg. From 6edea15bad923c585d18e19e28b452ed02c6365e Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 22:58:08 +0200 Subject: [PATCH 06/39] Require cancellation-guarded broad catches in the clan plan Task 2's socket methods copied a broad catch without the when (!cancellationToken.IsCancellationRequested) guard every sibling method in RustPlusSocketSource uses, which swallowed caller cancellation. Fix the plan's code and add the rule to the global constraints so later tasks do not repeat it. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-21-clan-support.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-21-clan-support.md b/docs/superpowers/plans/2026-07-21-clan-support.md index fe06b677..dca7810c 100644 --- a/docs/superpowers/plans/2026-07-21-clan-support.md +++ b/docs/superpowers/plans/2026-07-21-clan-support.md @@ -21,6 +21,7 @@ Every task's requirements implicitly include this section. - `CA1305`/`CA1307`/`CA1310`: pass `CultureInfo.InvariantCulture` on every format/parse and `StringComparison.Ordinal` on every string comparison. - `CA2007`: `.ConfigureAwait(false)` on every awaited task in `src/`. Test projects are exempt. - Broad `catch (Exception)` is allowed ONLY with an inline `#pragma warning disable CA1031` carrying a one-line justification comment, paired with a `[LoggerMessage]`-generated `static partial` log method. +- **Any broad catch in a method that takes a `CancellationToken` MUST be guarded `catch (Exception ex) when (!cancellationToken.IsCancellationRequested)`**, so caller-initiated cancellation propagates instead of being logged as a fault and swallowed into a fallback value. Every timeout-guarded method in `RustPlusSocketSource` already does this — match it. - Tests: plain xUnit `Assert.*` + NSubstitute. **No FluentAssertions.** `using Xunit` is a global using — never add it per file. Tests do not need `.ConfigureAwait(false)`. - Every new string key MUST be added to **both** `src/RustPlusBot.Localization/Strings.resx` and `Strings.fr.resx`. `StringsResourceParityTests` fails the build otherwise. - `dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder"` is a hard CI gate that fails on any diff. It is slow — run it ONCE at the very end (Task 13), not per task. @@ -705,7 +706,7 @@ and add the members (place them next to `SendTeamMessageAsync`): return ClanProbeResult.Unavailable; } #pragma warning disable CA1031 // Broad catch: a failed clan probe must degrade to Unavailable, never crash the caller. - catch (Exception ex) + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) #pragma warning restore CA1031 { LogQueryFailed(_logger, ex); @@ -733,7 +734,7 @@ and add the members (place them next to `SendTeamMessageAsync`): return false; } #pragma warning disable CA1031 // Broad catch: a failed MOTD write is reported to the user, not thrown. - catch (Exception ex) + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) #pragma warning restore CA1031 { LogQueryFailed(_logger, ex); From affaad9a778611a973a43201b8ceadb58afe6362 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 23:00:29 +0200 Subject: [PATCH 07/39] Guard broad clan-connection catches against caller cancellation; assert full clan snapshot mapping GetClanInfoAsync and SetClanMotdAsync were missing the `when (!cancellationToken.IsCancellationRequested)` guard that every other timeout-guarded method in RustPlusSocketSource uses on its broad catch, so a caller-initiated cancellation was being swallowed and mapped to a fallback value instead of propagating. Also strengthens Maps_a_populated_clan_to_a_snapshot to assert every field of the positional snapshot records, catching future constructor-argument mis-mapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Listening/RustPlusSocketSource.cs | 4 ++-- .../ClanMappingTests.cs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index c735ba0c..906ee74e 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -378,7 +378,7 @@ public async Task GetClanInfoAsync(TimeSpan timeout, Cancellati return ClanProbeResult.Unavailable; } #pragma warning disable CA1031 // Broad catch: a failed clan probe must degrade to Unavailable, never crash the caller. - catch (Exception ex) + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) #pragma warning restore CA1031 { LogQueryFailed(_logger, ex); @@ -406,7 +406,7 @@ public async Task SetClanMotdAsync(string motd, TimeSpan timeout, Cancella return false; } #pragma warning disable CA1031 // Broad catch: a failed MOTD write is reported to the user, not thrown. - catch (Exception ex) + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) #pragma warning restore CA1031 { LogQueryFailed(_logger, ex); diff --git a/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs index f165d42e..5809b4f3 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs @@ -82,13 +82,26 @@ public void Maps_a_populated_clan_to_a_snapshot() Assert.Equal(7UL, snapshot.Creator); Assert.Equal(1234L, snapshot.Score); Assert.Equal(8, snapshot.MaxMemberCount); + Assert.Equal("hold the line", snapshot.Motd); + Assert.Equal(new DateTimeOffset(2026, 2, 2, 0, 0, 0, TimeSpan.Zero), snapshot.MotdTimestamp); + Assert.Equal(9UL, snapshot.MotdAuthor); + Assert.Equal(255, snapshot.Color); + Assert.Equal(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), snapshot.Created); Assert.Single(snapshot.Roles); Assert.True(snapshot.Roles[0].CanSetMotd); + Assert.Equal(1, snapshot.Roles[0].RoleId); + Assert.Equal(0, snapshot.Roles[0].Rank); + Assert.Equal("Leader", snapshot.Roles[0].Name); Assert.Single(snapshot.Members); Assert.True(snapshot.Members[0].Online); Assert.Equal("founder", snapshot.Members[0].Notes); + Assert.Equal(1, snapshot.Members[0].RoleId); + Assert.Equal(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), snapshot.Members[0].Joined); + Assert.Equal(new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero), snapshot.Members[0].LastSeen); Assert.Single(snapshot.Invites); Assert.Equal(11UL, snapshot.Invites[0].SteamId); + Assert.Equal(7UL, snapshot.Invites[0].Recruiter); + Assert.Equal(new DateTimeOffset(2026, 3, 2, 0, 0, 0, TimeSpan.Zero), snapshot.Invites[0].Timestamp); Assert.NotNull(snapshot.LogoHash); } From 12833c8a477f51b2bc826fbae86063d2bc38f63c Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 23:16:23 +0200 Subject: [PATCH 08/39] Publish clan chat and clan state from the supervisor Subscribe to the socket's clan events for the lifetime of a connected window, probe the clan once on connect so state survives a restart, and publish both onto the event bus. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Supervisor/ConnectionSupervisor.cs | 81 ++++ .../ClanSupervisorTests.cs | 445 ++++++++++++++++++ .../Fakes/FakeRustSocketSource.cs | 19 + 3 files changed, 545 insertions(+) create mode 100644 tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index d5569cab..99020693 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -547,12 +547,34 @@ void OnStorage(object? sender, StorageMonitorTrigger trigger) } #pragma warning restore RCS1163 +#pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler delegate shape. + void OnClanMessage(object? sender, ClanChatLine line) + { + _ = PublishClanMessageAsync(key, activeSteamId, line); + } +#pragma warning restore RCS1163 + +#pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler delegate shape. + void OnClanChanged(object? sender, ClanProbeResult probe) + { + _ = PublishClanStateAsync(key, probe); + } +#pragma warning restore RCS1163 + var tracker = new TeamStateTracker(); connection.TeamMessageReceived += OnTeamMessage; connection.SmartDeviceTriggered += OnSmartDevice; connection.StorageMonitorTriggered += OnStorage; + connection.ClanMessageReceived += OnClanMessage; + connection.ClanChanged += OnClanChanged; _liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker); await PrimeDevicesAsync(key, connection, ct).ConfigureAwait(false); + + // Probe once on connect so clan state is correct after a bot restart, not only after the + // next in-game change. An Unavailable result publishes too: the consumer preserves state. + var clanProbe = await connection.GetClanInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); + await PublishClanStateAsync(key, clanProbe).ConfigureAwait(false); + using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, rigs, tracker, pollCts.Token), CancellationToken.None); @@ -580,6 +602,8 @@ void OnStorage(object? sender, StorageMonitorTrigger trigger) connection.TeamMessageReceived -= OnTeamMessage; connection.SmartDeviceTriggered -= OnSmartDevice; connection.StorageMonitorTriggered -= OnStorage; + connection.ClanMessageReceived -= OnClanMessage; + connection.ClanChanged -= OnClanChanged; } } @@ -1033,6 +1057,57 @@ private async Task PublishTeamMessageAsync((ulong Guild, Guid Server) key, ulong } } + private async Task PublishClanMessageAsync((ulong Guild, Guid Server) key, ulong activeSteamId, ClanChatLine line) + { + if (_disposed) + { + return; + } + + try + { + var evt = new ClanMessageReceivedEvent( + key.Guild, key.Server, line.SteamId, line.Name, line.Message, line.SteamId == activeSteamId); + // Supervisor-wide shutdown token, not a per-connection ct: an inbound line should publish + // regardless of one connection's reconnect cycle. + await eventBus.PublishAsync(evt, _shutdown.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a publish failure must not crash the socket callback. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPublishClanMessageFailed(logger, ex, key.Server); + } + } + + private async Task PublishClanStateAsync((ulong Guild, Guid Server) key, ClanProbeResult probe) + { + if (_disposed) + { + return; + } + + try + { + var evt = new ClanStateChangedEvent(key.Guild, key.Server, probe.Status, probe.Snapshot); + await eventBus.PublishAsync(evt, _shutdown.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a publish failure must not crash the socket callback. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPublishClanStateFailed(logger, ex, key.Server); + } + } + private async Task PrimeDevicesAsync( (ulong Guild, Guid Server) key, IRustServerConnection connection, @@ -1341,6 +1416,12 @@ await eventBus.PublishAsync( Message = "Publishing a received team message for server {ServerId} failed.")] private static partial void LogPublishTeamMessageFailed(ILogger logger, Exception exception, Guid serverId); + [LoggerMessage(Level = LogLevel.Error, Message = "Publishing a clan message for server {ServerId} failed.")] + private static partial void LogPublishClanMessageFailed(ILogger logger, Exception exception, Guid serverId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Publishing clan state for server {ServerId} failed.")] + private static partial void LogPublishClanStateFailed(ILogger logger, Exception exception, Guid serverId); + [LoggerMessage(Level = LogLevel.Warning, Message = "Listing smart devices to prime on server {ServerId} failed; priming skipped for this connection.")] private static partial void LogDeviceListFailed(ILogger logger, Exception exception, Guid serverId); diff --git a/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs new file mode 100644 index 00000000..f91a9b03 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs @@ -0,0 +1,445 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Discord.Notifications; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Connections.Tests.Fakes; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Connections; + +namespace RustPlusBot.Features.Connections.Tests; + +/// +/// Covers the supervisor's clan-event republishing: the connect-time probe, in-game clan changes, +/// clan chat lines (with active-player classification), and unsubscription on disconnect. +/// Mirrors the harness established by . +/// +public sealed class ClanSupervisorTests +{ + private static Harness CreateHarness(FakeRustSocketSource source) + { + var protector = Substitute.For(); + protector.Unprotect(Arg.Any()).Returns(c => c.Arg()); + + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var dm = Substitute.For(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(clock); + services.AddSingleton(protector); + services.AddSingleton(dm); + services.AddSingleton(); + + // Each scope opens its OWN connection to a shared-cache in-memory database, so the background + // supervisor loop and the test's polling never run concurrent commands on a single SqliteConnection + // (which throws "active statements" misuse errors). One kept-open connection keeps the in-memory DB alive. + var connectionString = $"DataSource=clansup-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + var keepAlive = new SqliteConnection(connectionString); + keepAlive.Open(); + using (var seed = new BotDbContext( + new DbContextOptionsBuilder().UseSqlite(connectionString).Options)) + { + seed.Database.Migrate(); + } + + services.AddSingleton(keepAlive); + services.AddScoped(_ => new BotDbContext( + new DbContextOptionsBuilder().UseSqlite(connectionString).Options)); + services.AddScoped(); + services + .AddScoped(); + + services.AddSingleton(source); + services.AddSingleton(Options.Create(new ConnectionOptions + { + ConnectTimeout = TimeSpan.FromSeconds(1), + InitialRetryDelay = TimeSpan.FromMilliseconds(5), + MaxRetryDelay = TimeSpan.FromMilliseconds(20), + HeartbeatInterval = TimeSpan.FromMilliseconds(20), + HeartbeatTimeout = TimeSpan.FromMilliseconds(200), + MarkerPollInterval = TimeSpan.FromMilliseconds(20), + MarkerPollFastInterval = TimeSpan.FromMilliseconds(20), + })); + services.AddSingleton(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + return new Harness + { + Provider = provider, + Dm = dm, + Supervisor = provider.GetRequiredService(), + Bus = provider.GetRequiredService(), + }; + } + + private static async Task<(Guid ServerId, Guid CredA, Guid CredB)> SeedAsync( + ServiceProvider provider, + CredentialStatus bStatus = CredentialStatus.Standby) + { + using var scope = provider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + var a = new PlayerCredential + { + GuildId = 10UL, + RustServerId = server.Id, + OwnerUserId = 1UL, + SteamId = 100UL, + ProtectedPlayerToken = "111", + Status = CredentialStatus.Active, + }; + var b = new PlayerCredential + { + GuildId = 10UL, + RustServerId = server.Id, + OwnerUserId = 2UL, + SteamId = 200UL, + ProtectedPlayerToken = "222", + Status = bStatus, + }; + await context.PlayerCredentials.AddRangeAsync(a, b); + await context.SaveChangesAsync(); + return (server.Id, a.Id, b.Id); + } + + private static async Task WaitUntilAsync(Func condition, CancellationToken ct) + { + while (!condition()) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(10, ct); + } + } + + [Fact] + public async Task Publishes_a_clan_state_event_on_connect() + { + var snapshot = new ClanSnapshot( + ClanId: 1L, + Name: "Clan", + Created: DateTimeOffset.UnixEpoch, + Creator: 100UL, + Motd: "MOTD", + MotdTimestamp: null, + MotdAuthor: null, + LogoHash: null, + Color: null, + MaxMemberCount: null, + Score: null, + Roles: [], + Members: [], + Invites: []); + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var captured = new System.Collections.Concurrent.ConcurrentQueue(); + // Register the subscription synchronously on this thread BEFORE any connection work runs. + // InMemoryEventBus registers the channel eagerly when SubscribeAsync is invoked, so buffering + // starts here. Deferring the call into the Task.Run below would race the supervisor's connect-time + // probe publish. + var stream = h.Bus.SubscribeAsync(cts.Token); + var subTask = Task.Run(async () => + { + await foreach (var e in stream) + { + captured.Enqueue(e); + } + }, CancellationToken.None); + + // Staged before EnsureConnectionAsync so the probe reads it as soon as the connect window opens — + // eliminates the race between the test assigning ClanProbe and the supervisor's connect-time read. + source.SetClanProbe(ClanProbeResult.From(snapshot)); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => !captured.IsEmpty, cts.Token); + + Assert.True(captured.TryPeek(out var evt)); + Assert.NotNull(evt); + Assert.Equal(ClanProbeStatus.HasClan, evt!.Status); + Assert.NotNull(evt.Snapshot); + Assert.Equal(1L, evt.Snapshot!.ClanId); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try + { + await subTask; + } + catch (OperationCanceledException) + { + /* expected */ + } + } + + [Fact] + public async Task Publishes_NoClan_on_connect_when_the_player_has_no_clan() + { + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var captured = new System.Collections.Concurrent.ConcurrentQueue(); + // Registered synchronously before any connection work runs — see comment above. + var stream = h.Bus.SubscribeAsync(cts.Token); + var subTask = Task.Run(async () => + { + await foreach (var e in stream) + { + captured.Enqueue(e); + } + }, CancellationToken.None); + + // FakeConnection.ClanProbe defaults to ClanProbeResult.NoClan — no staging required. + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => !captured.IsEmpty, cts.Token); + + Assert.True(captured.TryPeek(out var evt)); + Assert.NotNull(evt); + Assert.Equal(ClanProbeStatus.NoClan, evt!.Status); + Assert.Null(evt.Snapshot); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try + { + await subTask; + } + catch (OperationCanceledException) + { + /* expected */ + } + } + + [Fact] + public async Task Publishes_a_clan_state_event_when_the_socket_reports_a_change() + { + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var captured = new System.Collections.Concurrent.ConcurrentQueue(); + // Registered synchronously before any connection work runs — see comment in the first test above. + var stream = h.Bus.SubscribeAsync(cts.Token); + var subTask = Task.Run(async () => + { + await foreach (var e in stream) + { + captured.Enqueue(e); + } + }, CancellationToken.None); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + + // Wait for the connect-time probe's NoClan event (default) before raising the in-game change, so + // the two events are unambiguous and the second one asserted below is definitely the raised one. + // Its arrival also proves OnClanChanged is already subscribed (subscription happens before the + // connect-time probe call), so RaiseClanChanged below is guaranteed to reach the handler. + await WaitUntilAsync(() => !captured.IsEmpty, cts.Token); + Assert.NotNull(source.LastConnection); + source.LastConnection!.RaiseClanChanged(ClanProbeResult.NoClan); + + await WaitUntilAsync(() => captured.Count >= 2, cts.Token); + + var events = captured.ToArray(); + Assert.Equal(ClanProbeStatus.NoClan, events[1].Status); + Assert.Null(events[1].Snapshot); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try + { + await subTask; + } + catch (OperationCanceledException) + { + /* expected */ + } + } + + [Fact] + public async Task Publishes_clan_messages_and_flags_the_active_player() + { + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); // credential A: SteamId 100UL is active + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var captured = new System.Collections.Concurrent.ConcurrentQueue(); + // The connect-time ClanStateChangedEvent is published (see Step 4) only AFTER OnClanMessage/ + // OnClanChanged are subscribed, so waiting for it — rather than polling source.LastConnection — + // proves the handlers are wired before RaiseClanMessage is called below. Both subscriptions are + // registered synchronously here, before any connection work runs (see comment in the first test). + var stateCaptured = new System.Collections.Concurrent.ConcurrentQueue(); + var messageStream = h.Bus.SubscribeAsync(cts.Token); + var stateStream = h.Bus.SubscribeAsync(cts.Token); + var subTask = Task.Run(async () => + { + await foreach (var e in messageStream) + { + captured.Enqueue(e); + } + }, CancellationToken.None); + var stateSubTask = Task.Run(async () => + { + await foreach (var e in stateStream) + { + stateCaptured.Enqueue(e); + } + }, CancellationToken.None); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => !stateCaptured.IsEmpty, cts.Token); + + Assert.NotNull(source.LastConnection); + source.LastConnection!.RaiseClanMessage(new ClanChatLine(100UL, "Active", "hi", DateTimeOffset.UnixEpoch)); + source.LastConnection.RaiseClanMessage(new ClanChatLine(999UL, "Other", "yo", DateTimeOffset.UnixEpoch)); + + await WaitUntilAsync(() => captured.Count >= 2, cts.Token); + + var events = captured.ToArray(); + var fromActive = Assert.Single(events, e => e.SenderSteamId == 100UL); + Assert.True(fromActive.FromActivePlayer); + Assert.Equal("hi", fromActive.Message); + Assert.Equal("Active", fromActive.SenderName); + + var fromOther = Assert.Single(events, e => e.SenderSteamId == 999UL); + Assert.False(fromOther.FromActivePlayer); + Assert.Equal("yo", fromOther.Message); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try + { + await subTask; + } + catch (OperationCanceledException) + { + /* expected */ + } + + try + { + await stateSubTask; + } + catch (OperationCanceledException) + { + /* expected */ + } + } + + [Fact] + public async Task Stops_publishing_clan_events_after_the_socket_closes() + { + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var captured = new System.Collections.Concurrent.ConcurrentQueue(); + // The connect-time ClanStateChangedEvent is published only AFTER OnClanMessage is subscribed (see + // Step 4), so waiting for it proves the handler was really wired before it gets unsubscribed below — + // otherwise this test would trivially pass because the handler was never subscribed in the first + // place. Both subscriptions are registered synchronously here (see comment in the first test). + var stateCaptured = new System.Collections.Concurrent.ConcurrentQueue(); + var messageStream = h.Bus.SubscribeAsync(cts.Token); + var stateStream = h.Bus.SubscribeAsync(cts.Token); + var subTask = Task.Run(async () => + { + await foreach (var e in messageStream) + { + captured.Enqueue(e); + } + }, CancellationToken.None); + var stateSubTask = Task.Run(async () => + { + await foreach (var e in stateStream) + { + stateCaptured.Enqueue(e); + } + }, CancellationToken.None); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => !stateCaptured.IsEmpty, cts.Token); + + Assert.NotNull(source.LastConnection); + var closedConnection = source.LastConnection!; + + // Stop the connection loop — this joins RunConnectedAsync's finally block, which unsubscribes + // the clan handlers, before StopAsync returns. + await h.Supervisor.StopAsync(10UL, serverId); + + // The now-detached fake connection is still holding its event delegate references (it wasn't + // disposed by the test); raising on it must reach no subscribed handler, so nothing publishes. + closedConnection.RaiseClanMessage(new ClanChatLine(100UL, "Active", "hi", DateTimeOffset.UnixEpoch)); + + // Give the bus a beat to deliver anything that (incorrectly) got published. + await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token); + + Assert.Empty(captured); + + await cts.CancelAsync(); + try + { + await subTask; + } + catch (OperationCanceledException) + { + /* expected */ + } + + try + { + await stateSubTask; + } + catch (OperationCanceledException) + { + /* expected */ + } + } + + private sealed class Harness : IAsyncDisposable + { + public required ServiceProvider Provider { get; init; } + public required IUserDmSender Dm { get; init; } + public required ConnectionSupervisor Supervisor { get; init; } + public required IEventBus Bus { get; init; } + + public async ValueTask DisposeAsync() + { + await Supervisor.StopAllAsync(); + await Provider.DisposeAsync(); + } + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index f2f5e5de..daeeeef3 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -28,6 +28,7 @@ internal sealed class FakeRustSocketSource : IRustSocketSource private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0); private IReadOnlyList _pendingMonuments = []; + private ClanProbeResult? _pendingClanProbe; /// Number of times has been called. Safe to read from any thread. public int CreateCount => Volatile.Read(ref _createCount); @@ -83,6 +84,14 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p _pendingDeviceStates.Clear(); + // Transfer any pre-staged clan probe so it is in place before the supervisor's connect-time probe + // reads it. Reset after transfer so the staging applies to the NEXT connection only. + if (_pendingClanProbe is { } clanProbe) + { + connection.ClanProbe = clanProbe; + _pendingClanProbe = null; + } + LastConnection = connection; return connection; } @@ -110,6 +119,16 @@ public void EnqueueMarkers(IReadOnlyList markers) => /// The monument list to return from . public void SetMonuments(IReadOnlyList monuments) => _pendingMonuments = monuments; + /// + /// Pre-stages the probe result returned by for the NEXT + /// connection created by . Transferred to the new connection at creation time, before + /// the supervisor's connect-time clan probe runs, eliminating the setup race between the test assigning + /// and the supervisor reading it. Call this before + /// . + /// + /// The probe result to return from . + public void SetClanProbe(ClanProbeResult probe) => _pendingClanProbe = probe; + /// /// Pre-stages storage contents for a given entity, to be transferred to the NEXT connection created by /// . Eliminates the setup race when the prime loop reads contents before the test can From 4da6b283e20230bb25bdbd9b1127197df1b2ea96 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 23:31:07 +0200 Subject: [PATCH 09/39] Persist clan state and cached player names Add the ClanState and ClanPlayerName entities, their configurations, the scoped IClanStore, and the ClanSupport migration. Members, roles and invites are stored as JSON because they are only ever read and written whole. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Clans/ClanPlayerName.cs | 23 + src/RustPlusBot.Domain/Clans/ClanState.cs | 59 ++ src/RustPlusBot.Persistence/BotDbContext.cs | 11 +- .../Clans/ClanSnapshotSerializer.cs | 42 + .../Clans/ClanStore.cs | 168 ++++ .../Clans/IClanStore.cs | 69 ++ .../ClanPlayerNameConfiguration.cs | 21 + .../Configurations/ClanStateConfiguration.cs | 24 + .../20260721212722_ClanSupport.Designer.cs | 848 ++++++++++++++++++ .../Migrations/20260721212722_ClanSupport.cs | 79 ++ .../Migrations/BotDbContextModelSnapshot.cs | 109 ++- .../PersistenceServiceCollectionExtensions.cs | 2 + .../RustPlusBot.Persistence.csproj | 5 + .../ClanStoreTests.cs | 265 ++++++ 14 files changed, 1723 insertions(+), 2 deletions(-) create mode 100644 src/RustPlusBot.Domain/Clans/ClanPlayerName.cs create mode 100644 src/RustPlusBot.Domain/Clans/ClanState.cs create mode 100644 src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs create mode 100644 src/RustPlusBot.Persistence/Clans/ClanStore.cs create mode 100644 src/RustPlusBot.Persistence/Clans/IClanStore.cs create mode 100644 src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.cs create mode 100644 src/RustPlusBot.Persistence/Configurations/ClanStateConfiguration.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260721212722_ClanSupport.Designer.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260721212722_ClanSupport.cs create mode 100644 tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs diff --git a/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs new file mode 100644 index 00000000..40b9db9c --- /dev/null +++ b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs @@ -0,0 +1,23 @@ +namespace RustPlusBot.Domain.Clans; + +/// +/// A cached Steam64 id to display-name mapping. The clan API reports members by id only, so names +/// are harvested from clan chat and team snapshots, which do carry them. +/// +public sealed class ClanPlayerName +{ + /// The owning guild snowflake. + public ulong GuildId { get; set; } + + /// The server id (FK to RustServer). + public Guid ServerId { get; set; } + + /// The Steam64 id of the player. + public ulong SteamId { get; set; } + + /// The most recently observed display name. + public string Name { get; set; } = string.Empty; + + /// When the name was last observed (UTC). + public DateTimeOffset UpdatedUtc { get; set; } +} diff --git a/src/RustPlusBot.Domain/Clans/ClanState.cs b/src/RustPlusBot.Domain/Clans/ClanState.cs new file mode 100644 index 00000000..e121b5c3 --- /dev/null +++ b/src/RustPlusBot.Domain/Clans/ClanState.cs @@ -0,0 +1,59 @@ +namespace RustPlusBot.Domain.Clans; + +/// +/// The latest known clan snapshot for one (guild, server). Row presence is the single source of +/// truth for whether the paired player is in a clan. +/// +public sealed class ClanState +{ + /// The owning guild snowflake. + public ulong GuildId { get; set; } + + /// The server id (FK to RustServer; primary key, one row per server). + public Guid ServerId { get; set; } + + /// The in-game clan identifier. + public long ClanId { get; set; } + + /// The clan's display name. + public string Name { get; set; } = string.Empty; + + /// When the clan was created (UTC). + public DateTimeOffset Created { get; set; } + + /// Steam64 id of the clan creator. + public ulong Creator { get; set; } + + /// The message of the day, or null when unset. + public string? Motd { get; set; } + + /// When the MOTD was last changed (UTC), or null. + public DateTimeOffset? MotdTimestamp { get; set; } + + /// Steam64 id of the player who last changed the MOTD, or null. + public ulong? MotdAuthor { get; set; } + + /// Stable hash of the clan logo, or null when no logo is set. + public string? LogoHash { get; set; } + + /// The clan colour as a packed ARGB integer, or null. + public int? Color { get; set; } + + /// The maximum member count, or null when uncapped. + public int? MaxMemberCount { get; set; } + + /// The clan score, or null when the server does not report one. + public long? Score { get; set; } + + /// The clan's roles, serialized as JSON. + public string RolesJson { get; set; } = "[]"; + + /// The clan's members, serialized as JSON. + public string MembersJson { get; set; } = "[]"; + + /// The clan's pending invites, serialized as JSON. + public string InvitesJson { get; set; } = "[]"; + + /// When this snapshot was last confirmed (UTC). + public DateTimeOffset LastSeenUtc { get; set; } +} diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs index 7c3b2d79..4d822c55 100644 --- a/src/RustPlusBot.Persistence/BotDbContext.cs +++ b/src/RustPlusBot.Persistence/BotDbContext.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Persistord.Core; using RustPlusBot.Domain.Alarms; +using RustPlusBot.Domain.Clans; using RustPlusBot.Domain.Commands; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; @@ -68,6 +69,12 @@ public sealed class BotDbContext(DbContextOptions options) : Disco /// Anchored bot messages, edited in place. public DbSet ProvisionedMessages => Set(); + /// The latest known clan snapshot per server. + public DbSet ClanStates => Set(); + + /// Cached Steam id to display-name mappings for clan members. + public DbSet ClanPlayerNames => Set(); + /// protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -89,6 +96,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .ApplyConfiguration(new EventSubscriptionConfiguration()) .ApplyConfiguration(new ProvisionedCategoryConfiguration()) .ApplyConfiguration(new ProvisionedChannelConfiguration()) - .ApplyConfiguration(new ProvisionedMessageConfiguration()); + .ApplyConfiguration(new ProvisionedMessageConfiguration()) + .ApplyConfiguration(new ClanStateConfiguration()) + .ApplyConfiguration(new ClanPlayerNameConfiguration()); } } diff --git a/src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs b/src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs new file mode 100644 index 00000000..ac12f3ac --- /dev/null +++ b/src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Persistence.Clans; + +/// +/// Serializes the clan's collections to and from the JSON columns on ClanState. The +/// collections are only ever read and written whole (diff, then render) and nothing queries +/// across them, so three normalised tables would add migration burden for no query benefit. +/// +internal static class ClanSnapshotSerializer +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + /// Serializes a collection to its JSON column value. + /// The element type. + /// The items to serialize. + /// The JSON text. + public static string Serialize(IReadOnlyList items) => JsonSerializer.Serialize(items, Options); + + /// Deserializes a JSON column value, yielding an empty list when the text is unusable. + /// The element type. + /// The stored JSON text. + /// The deserialized items, or an empty list. + public static IReadOnlyList Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json, Options) ?? []; + } + catch (JsonException) + { + // A corrupted column must not crash the bot; an empty list re-reads as a full change set. + return []; + } + } +} diff --git a/src/RustPlusBot.Persistence/Clans/ClanStore.cs b/src/RustPlusBot.Persistence/Clans/ClanStore.cs new file mode 100644 index 00000000..31a32388 --- /dev/null +++ b/src/RustPlusBot.Persistence/Clans/ClanStore.cs @@ -0,0 +1,168 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Domain.Clans; + +namespace RustPlusBot.Persistence.Clans; + +/// EF-backed . +/// The bot database context. +internal sealed class ClanStore(BotDbContext db) : IClanStore +{ + /// + public async Task GetAsync( + ulong guildId, + Guid serverId, + CancellationToken cancellationToken = default) + { + var row = await db.ClanStates + .AsNoTracking() + .FirstOrDefaultAsync(s => s.ServerId == serverId && s.GuildId == guildId, cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + return null; + } + + return new ClanSnapshot( + row.ClanId, + row.Name, + row.Created, + row.Creator, + row.Motd, + row.MotdTimestamp, + row.MotdAuthor, + row.LogoHash, + row.Color, + row.MaxMemberCount, + row.Score, + ClanSnapshotSerializer.Deserialize(row.RolesJson), + ClanSnapshotSerializer.Deserialize(row.MembersJson), + ClanSnapshotSerializer.Deserialize(row.InvitesJson)); + } + + /// + public async Task SaveAsync( + ulong guildId, + Guid serverId, + ClanSnapshot snapshot, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(snapshot); + + var row = await db.ClanStates + .FirstOrDefaultAsync(s => s.ServerId == serverId && s.GuildId == guildId, cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + row = new ClanState + { + GuildId = guildId, ServerId = serverId + }; + db.ClanStates.Add(row); + } + + row.ClanId = snapshot.ClanId; + row.Name = snapshot.Name; + row.Created = snapshot.Created; + row.Creator = snapshot.Creator; + row.Motd = snapshot.Motd; + row.MotdTimestamp = snapshot.MotdTimestamp; + row.MotdAuthor = snapshot.MotdAuthor; + row.LogoHash = snapshot.LogoHash; + row.Color = snapshot.Color; + row.MaxMemberCount = snapshot.MaxMemberCount; + row.Score = snapshot.Score; + row.RolesJson = ClanSnapshotSerializer.Serialize(snapshot.Roles); + row.MembersJson = ClanSnapshotSerializer.Serialize(snapshot.Members); + row.InvitesJson = ClanSnapshotSerializer.Serialize(snapshot.Invites); + row.LastSeenUtc = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ClearAsync( + ulong guildId, + Guid serverId, + CancellationToken cancellationToken = default) + { + var row = await db.ClanStates + .FirstOrDefaultAsync(s => s.ServerId == serverId && s.GuildId == guildId, cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + return false; + } + + db.ClanStates.Remove(row); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return true; + } + + /// + public Task HasClanAsync( + ulong guildId, + Guid serverId, + CancellationToken cancellationToken = default) => + db.ClanStates.AnyAsync(s => s.ServerId == serverId && s.GuildId == guildId, cancellationToken); + + /// + public async Task> GetNamesAsync( + ulong guildId, + Guid serverId, + IReadOnlyCollection steamIds, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(steamIds); + + if (steamIds.Count == 0) + { + return new Dictionary(); + } + + return await db.ClanPlayerNames + .AsNoTracking() + .Where(n => n.ServerId == serverId && steamIds.Contains(n.SteamId)) + .ToDictionaryAsync(n => n.SteamId, n => n.Name, cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task RecordNameAsync( + ulong guildId, + Guid serverId, + ulong steamId, + string name, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(name); + + var row = await db.ClanPlayerNames + .FirstOrDefaultAsync( + n => n.ServerId == serverId && n.GuildId == guildId && n.SteamId == steamId, + cancellationToken) + .ConfigureAwait(false); + + if (row is not null && string.Equals(row.Name, name, StringComparison.Ordinal)) + { + return; + } + + if (row is null) + { + row = new ClanPlayerName + { + GuildId = guildId, ServerId = serverId, SteamId = steamId + }; + db.ClanPlayerNames.Add(row); + } + + row.Name = name; + row.UpdatedUtc = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/RustPlusBot.Persistence/Clans/IClanStore.cs b/src/RustPlusBot.Persistence/Clans/IClanStore.cs new file mode 100644 index 00000000..29c13fa4 --- /dev/null +++ b/src/RustPlusBot.Persistence/Clans/IClanStore.cs @@ -0,0 +1,69 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Persistence.Clans; + +/// +/// Persists the latest known clan snapshot per (guild, server) and caches Steam id to display-name +/// mappings for clan members. Row presence in the underlying clan-state table is the single source +/// of truth for whether the paired player currently belongs to a clan. +/// +public interface IClanStore +{ + /// Gets the latest stored clan snapshot, or null when no clan is currently stored. + /// The owning guild snowflake. + /// The server id. + /// A cancellation token. + /// The stored snapshot, or null when the server has no clan row. + Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + + /// Overwrites the stored clan snapshot for the server, creating the row if absent. + /// The owning guild snowflake. + /// The server id. + /// The snapshot to persist. + /// A cancellation token. + /// A task that completes when the snapshot has been persisted. + Task SaveAsync(ulong guildId, Guid serverId, ClanSnapshot snapshot, CancellationToken cancellationToken = default); + + /// Removes the stored clan snapshot for the server, if any. + /// The owning guild snowflake. + /// The server id. + /// A cancellation token. + /// True when a row actually existed and was removed; false when there was nothing to clear. + Task ClearAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + + /// Determines whether the server currently has a stored clan snapshot. + /// The owning guild snowflake. + /// The server id. + /// A cancellation token. + /// True when a clan row exists for the server. + Task HasClanAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + + /// Looks up cached display names for the given Steam ids. + /// The owning guild snowflake. + /// The server id. + /// The Steam64 ids to resolve. + /// A cancellation token. + /// + /// A dictionary of the requested ids to their cached names. Ids with no cached name are omitted. + /// Empty when is empty; the database is not queried in that case. + /// + Task> GetNamesAsync( + ulong guildId, + Guid serverId, + IReadOnlyCollection steamIds, + CancellationToken cancellationToken = default); + + /// Records or updates the cached display name for a Steam id. + /// The owning guild snowflake. + /// The server id. + /// The Steam64 id of the player. + /// The observed display name. + /// A cancellation token. + /// A task that completes when the name has been persisted (or the no-op has been recognised). + Task RecordNameAsync( + ulong guildId, + Guid serverId, + ulong steamId, + string name, + CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.cs new file mode 100644 index 00000000..e9534974 --- /dev/null +++ b/src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RustPlusBot.Domain.Clans; +using RustPlusBot.Domain.Servers; + +namespace RustPlusBot.Persistence.Configurations; + +internal sealed class ClanPlayerNameConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.HasKey(n => new { n.ServerId, n.SteamId }); + builder.Property(n => n.Name).HasMaxLength(64); + + builder.HasOne() + .WithMany() + .HasForeignKey(n => n.ServerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/RustPlusBot.Persistence/Configurations/ClanStateConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ClanStateConfiguration.cs new file mode 100644 index 00000000..d008be08 --- /dev/null +++ b/src/RustPlusBot.Persistence/Configurations/ClanStateConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RustPlusBot.Domain.Clans; +using RustPlusBot.Domain.Servers; + +namespace RustPlusBot.Persistence.Configurations; + +internal sealed class ClanStateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.HasKey(s => s.ServerId); + builder.Property(s => s.Name).HasMaxLength(128); + builder.Property(s => s.Motd).HasMaxLength(1024); + builder.Property(s => s.LogoHash).HasMaxLength(64); + + // Removing a RustServer cascades to its clan state so no orphaned snapshot lingers. + builder.HasOne() + .WithOne() + .HasForeignKey(s => s.ServerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260721212722_ClanSupport.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260721212722_ClanSupport.Designer.cs new file mode 100644 index 00000000..94e4c99b --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260721212722_ClanSupport.Designer.cs @@ -0,0 +1,848 @@ +// +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("20260721212722_ClanSupport")] + partial class ClanSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + 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.Clans.ClanPlayerName", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("ServerId", "SteamId"); + + b.ToTable("ClanPlayerNames"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("ClanId") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Creator") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("InvitesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LogoHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxMemberCount") + .HasColumnType("INTEGER"); + + b.Property("MembersJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Motd") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("MotdAuthor") + .HasColumnType("INTEGER"); + + b.Property("MotdTimestamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RolesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.ToTable("ClanStates"); + }); + + 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.Clans.ClanPlayerName", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Clans.ClanState", "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/20260721212722_ClanSupport.cs b/src/RustPlusBot.Persistence/Migrations/20260721212722_ClanSupport.cs new file mode 100644 index 00000000..d5bb24c8 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260721212722_ClanSupport.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class ClanSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ClanPlayerNames", + columns: table => new + { + ServerId = table.Column(type: "TEXT", nullable: false), + SteamId = table.Column(type: "INTEGER", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 64, nullable: false), + UpdatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClanPlayerNames", x => new { x.ServerId, x.SteamId }); + table.ForeignKey( + name: "FK_ClanPlayerNames_RustServers_ServerId", + column: x => x.ServerId, + principalTable: "RustServers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ClanStates", + columns: table => new + { + ServerId = table.Column(type: "TEXT", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + ClanId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + Creator = table.Column(type: "INTEGER", nullable: false), + Motd = table.Column(type: "TEXT", maxLength: 1024, nullable: true), + MotdTimestamp = table.Column(type: "TEXT", nullable: true), + MotdAuthor = table.Column(type: "INTEGER", nullable: true), + LogoHash = table.Column(type: "TEXT", maxLength: 64, nullable: true), + Color = table.Column(type: "INTEGER", nullable: true), + MaxMemberCount = table.Column(type: "INTEGER", nullable: true), + Score = table.Column(type: "INTEGER", nullable: true), + RolesJson = table.Column(type: "TEXT", nullable: false), + MembersJson = table.Column(type: "TEXT", nullable: false), + InvitesJson = table.Column(type: "TEXT", nullable: false), + LastSeenUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClanStates", x => x.ServerId); + table.ForeignKey( + name: "FK_ClanStates_RustServers_ServerId", + column: x => x.ServerId, + principalTable: "RustServers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ClanPlayerNames"); + + migrationBuilder.DropTable( + name: "ClanStates"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index fba645bc..3580c328 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class BotDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => { @@ -178,6 +178,95 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SmartAlarms"); }); + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("ServerId", "SteamId"); + + b.ToTable("ClanPlayerNames"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("ClanId") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Creator") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("InvitesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("LogoHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxMemberCount") + .HasColumnType("INTEGER"); + + b.Property("MembersJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Motd") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("MotdAuthor") + .HasColumnType("INTEGER"); + + b.Property("MotdTimestamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RolesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.ToTable("ClanStates"); + }); + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => { b.Property("ServerId") @@ -655,6 +744,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Clans.ClanState", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => { b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs index 63958d1f..b6232167 100644 --- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Persistence.Alarms; +using RustPlusBot.Persistence.Clans; using RustPlusBot.Persistence.Commands; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Credentials; @@ -47,6 +48,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj b/src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj index 9283a5e9..868ce442 100644 --- a/src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj +++ b/src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj @@ -1,5 +1,10 @@  + + + + + diff --git a/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs new file mode 100644 index 00000000..0f8a7efb --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs @@ -0,0 +1,265 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Persistence.Tests; + +/// Unit tests for . +public sealed class ClanStoreTests +{ + private static (ClanStore Store, BotDbContext Context, SqliteConnection Conn) Create() + { + var (context, connection) = SqliteContextFixture.Create(); + return (new ClanStore(context), context, connection); + } + + private static async Task SeedServerAsync(BotDbContext context, ulong guildId = 10UL) + { + var server = new RustServer + { + GuildId = guildId, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + return server.Id; + } + + private static ClanSnapshot CreateSnapshot() => + new( + ClanId: 42L, + Name: "The Clan", + Created: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + Creator: 111UL, + Motd: "Welcome", + MotdTimestamp: new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero), + MotdAuthor: 111UL, + LogoHash: "abc123", + Color: unchecked((int)0xFF00FF00), + MaxMemberCount: 50, + Score: 12345L, + Roles: + [ + new ClanRoleSnapshot(0, 0, "Leader", true, true, true, true, true, true, true, true, true), + new ClanRoleSnapshot(1, 1, "Member", false, false, false, false, false, false, false, false, false), + ], + Members: + [ + new ClanMemberSnapshot( + 111UL, + 0, + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 1, 5, 0, 0, 0, TimeSpan.Zero), + null, + false), + new ClanMemberSnapshot( + 222UL, + 1, + new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 1, 6, 0, 0, 0, TimeSpan.Zero), + "Trusted officer", + true), + new ClanMemberSnapshot( + 333UL, + 1, + new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 1, 7, 0, 0, 0, TimeSpan.Zero), + null, + true), + ], + Invites: + [ + new ClanInviteSnapshot(444UL, 111UL, new DateTimeOffset(2026, 1, 8, 0, 0, 0, TimeSpan.Zero)), + ]); + + [Fact] + public async Task Returns_null_when_no_clan_is_stored() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + var result = await store.GetAsync(10UL, serverId); + + Assert.Null(result); + } + + [Fact] + public async Task Round_trips_a_snapshot_including_members_roles_and_invites() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + var snapshot = CreateSnapshot(); + + await store.SaveAsync(10UL, serverId, snapshot); + var result = await store.GetAsync(10UL, serverId); + + Assert.NotNull(result); + Assert.Equal(snapshot.ClanId, result.ClanId); + Assert.Equal(snapshot.Name, result.Name); + Assert.Equal(snapshot.Created, result.Created); + Assert.Equal(snapshot.Creator, result.Creator); + Assert.Equal(snapshot.Motd, result.Motd); + Assert.Equal(snapshot.MotdTimestamp, result.MotdTimestamp); + Assert.Equal(snapshot.MotdAuthor, result.MotdAuthor); + Assert.Equal(snapshot.LogoHash, result.LogoHash); + Assert.Equal(snapshot.Color, result.Color); + Assert.Equal(snapshot.MaxMemberCount, result.MaxMemberCount); + Assert.Equal(snapshot.Score, result.Score); + + Assert.Equal(snapshot.Roles.Count, result.Roles.Count); + for (var i = 0; i < snapshot.Roles.Count; i++) + { + Assert.Equal(snapshot.Roles[i], result.Roles[i]); + } + + Assert.Equal(snapshot.Members.Count, result.Members.Count); + for (var i = 0; i < snapshot.Members.Count; i++) + { + Assert.Equal(snapshot.Members[i], result.Members[i]); + } + + Assert.Equal(snapshot.Invites.Count, result.Invites.Count); + for (var i = 0; i < snapshot.Invites.Count; i++) + { + Assert.Equal(snapshot.Invites[i], result.Invites[i]); + } + } + + [Fact] + public async Task Overwrites_the_previous_snapshot_on_save() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.SaveAsync(10UL, serverId, CreateSnapshot()); + + var updated = CreateSnapshot() with + { + Name = "Renamed Clan", Score = 99999L + }; + await store.SaveAsync(10UL, serverId, updated); + var result = await store.GetAsync(10UL, serverId); + + Assert.NotNull(result); + Assert.Equal("Renamed Clan", result.Name); + Assert.Equal(99999L, result.Score); + Assert.Single(await context.ClanStates.ToListAsync()); + } + + [Fact] + public async Task HasClan_is_false_before_save_and_true_after() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + Assert.False(await store.HasClanAsync(10UL, serverId)); + + await store.SaveAsync(10UL, serverId, CreateSnapshot()); + + Assert.True(await store.HasClanAsync(10UL, serverId)); + } + + [Fact] + public async Task Clear_removes_the_row_and_reports_true_only_the_first_time() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.SaveAsync(10UL, serverId, CreateSnapshot()); + + var first = await store.ClearAsync(10UL, serverId); + var second = await store.ClearAsync(10UL, serverId); + + Assert.True(first); + Assert.False(second); + Assert.False(await store.HasClanAsync(10UL, serverId)); + } + + [Fact] + public async Task Deleting_the_server_cascades_to_the_clan_state() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.SaveAsync(10UL, serverId, CreateSnapshot()); + + var server = await context.RustServers.SingleAsync(s => s.Id == serverId); + context.RustServers.Remove(server); + await context.SaveChangesAsync(); + + Assert.Empty(await context.ClanStates.ToListAsync()); + } + + [Fact] + public async Task Records_and_reads_back_player_names() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + await store.RecordNameAsync(10UL, serverId, 111UL, "Alice"); + + var names = await store.GetNamesAsync(10UL, serverId, [111UL]); + + Assert.Equal("Alice", names[111UL]); + } + + [Fact] + public async Task Name_lookup_returns_only_the_requested_ids() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.RecordNameAsync(10UL, serverId, 111UL, "Alice"); + await store.RecordNameAsync(10UL, serverId, 222UL, "Bob"); + await store.RecordNameAsync(10UL, serverId, 333UL, "Carol"); + + var names = await store.GetNamesAsync(10UL, serverId, [111UL, 333UL]); + + Assert.Equal(2, names.Count); + Assert.Equal("Alice", names[111UL]); + Assert.Equal("Carol", names[333UL]); + Assert.False(names.ContainsKey(222UL)); + } + + [Fact] + public async Task Name_lookup_returns_empty_without_querying_for_no_ids() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + var names = await store.GetNamesAsync(10UL, serverId, []); + + Assert.Empty(names); + } + + [Fact] + public async Task Recording_a_name_twice_updates_rather_than_duplicating() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + await store.RecordNameAsync(10UL, serverId, 111UL, "Alice"); + await store.RecordNameAsync(10UL, serverId, 111UL, "Alicia"); + + var names = await store.GetNamesAsync(10UL, serverId, [111UL]); + Assert.Equal("Alicia", names[111UL]); + Assert.Single(await context.ClanPlayerNames.ToListAsync()); + } +} From 6eedfa6590f8716916a1881d2fd884c1784ca0ba Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 23:42:35 +0200 Subject: [PATCH 10/39] Fix ClanStore review findings: GuildId-agnostic name lookup, IClock injection, skip-write test coverage RecordNameAsync's existing-row lookup filtered on GuildId even though ClanPlayerName's primary key is (ServerId, SteamId) only, risking a DbUpdateException if a row's stored GuildId ever diverged from the caller-passed value. ClanStore also called DateTimeOffset.UtcNow directly instead of injecting IClock like every other store in the persistence layer, leaving its writes untestable with a controlled clock. Added a test that proves RecordNameAsync's skip-write guard by asserting UpdatedUtc is unchanged when the same name is recorded twice, and renamed the no-ids lookup test since an empty IN() clause returns nothing regardless of whether the empty-collection guard exists, so the assertion cannot actually prove the guard runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Clans/ClanStore.cs | 10 ++-- .../ClanStoreTests.cs | 54 ++++++++++++++----- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/RustPlusBot.Persistence/Clans/ClanStore.cs b/src/RustPlusBot.Persistence/Clans/ClanStore.cs index 31a32388..c8f54b0f 100644 --- a/src/RustPlusBot.Persistence/Clans/ClanStore.cs +++ b/src/RustPlusBot.Persistence/Clans/ClanStore.cs @@ -1,12 +1,14 @@ using Microsoft.EntityFrameworkCore; using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Clans; namespace RustPlusBot.Persistence.Clans; /// EF-backed . /// The bot database context. -internal sealed class ClanStore(BotDbContext db) : IClanStore +/// Supplies write timestamps. +internal sealed class ClanStore(BotDbContext db, IClock clock) : IClanStore { /// public async Task GetAsync( @@ -77,7 +79,7 @@ public async Task SaveAsync( row.RolesJson = ClanSnapshotSerializer.Serialize(snapshot.Roles); row.MembersJson = ClanSnapshotSerializer.Serialize(snapshot.Members); row.InvitesJson = ClanSnapshotSerializer.Serialize(snapshot.Invites); - row.LastSeenUtc = DateTimeOffset.UtcNow; + row.LastSeenUtc = clock.UtcNow; await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } @@ -142,7 +144,7 @@ public async Task RecordNameAsync( var row = await db.ClanPlayerNames .FirstOrDefaultAsync( - n => n.ServerId == serverId && n.GuildId == guildId && n.SteamId == steamId, + n => n.ServerId == serverId && n.SteamId == steamId, cancellationToken) .ConfigureAwait(false); @@ -161,7 +163,7 @@ public async Task RecordNameAsync( } row.Name = name; - row.UpdatedUtc = DateTimeOffset.UtcNow; + row.UpdatedUtc = clock.UtcNow; await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } diff --git a/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs index 0f8a7efb..d44157f4 100644 --- a/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs @@ -1,6 +1,8 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using NSubstitute; using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.Clans; @@ -9,10 +11,12 @@ namespace RustPlusBot.Persistence.Tests; /// Unit tests for . public sealed class ClanStoreTests { - private static (ClanStore Store, BotDbContext Context, SqliteConnection Conn) Create() + private static (ClanStore Store, BotDbContext Context, SqliteConnection Conn, IClock Clock) Create() { var (context, connection) = SqliteContextFixture.Create(); - return (new ClanStore(context), context, connection); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + return (new ClanStore(context, clock), context, connection, clock); } private static async Task SeedServerAsync(BotDbContext context, ulong guildId = 10UL) @@ -76,7 +80,7 @@ private static ClanSnapshot CreateSnapshot() => [Fact] public async Task Returns_null_when_no_clan_is_stored() { - var (store, context, conn) = Create(); + var (store, context, conn, _) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -89,7 +93,7 @@ public async Task Returns_null_when_no_clan_is_stored() [Fact] public async Task Round_trips_a_snapshot_including_members_roles_and_invites() { - var (store, context, conn) = Create(); + var (store, context, conn, _) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -133,7 +137,7 @@ public async Task Round_trips_a_snapshot_including_members_roles_and_invites() [Fact] public async Task Overwrites_the_previous_snapshot_on_save() { - var (store, context, conn) = Create(); + var (store, context, conn, _) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -155,7 +159,7 @@ public async Task Overwrites_the_previous_snapshot_on_save() [Fact] public async Task HasClan_is_false_before_save_and_true_after() { - var (store, context, conn) = Create(); + var (store, context, conn, _) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -170,7 +174,7 @@ public async Task HasClan_is_false_before_save_and_true_after() [Fact] public async Task Clear_removes_the_row_and_reports_true_only_the_first_time() { - var (store, context, conn) = Create(); + var (store, context, conn, _) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -187,7 +191,7 @@ public async Task Clear_removes_the_row_and_reports_true_only_the_first_time() [Fact] public async Task Deleting_the_server_cascades_to_the_clan_state() { - var (store, context, conn) = Create(); + var (store, context, conn, _) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -203,7 +207,7 @@ public async Task Deleting_the_server_cascades_to_the_clan_state() [Fact] public async Task Records_and_reads_back_player_names() { - var (store, context, conn) = Create(); + var (store, context, conn, _) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -218,7 +222,7 @@ public async Task Records_and_reads_back_player_names() [Fact] public async Task Name_lookup_returns_only_the_requested_ids() { - var (store, context, conn) = Create(); + var (store, context, conn, _) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -235,12 +239,17 @@ public async Task Name_lookup_returns_only_the_requested_ids() } [Fact] - public async Task Name_lookup_returns_empty_without_querying_for_no_ids() + public async Task Name_lookup_returns_empty_for_no_ids() { - var (store, context, conn) = Create(); + // Note: an empty `IN ()` clause returns no rows whether or not the empty-collection guard + // exists in GetNamesAsync, so this cannot prove the guard is what produced the empty result. + // Seeding a row for the same server at least confirms the empty result isn't a side effect of + // an empty table. + var (store, context, conn, _) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); + await store.RecordNameAsync(10UL, serverId, 111UL, "Alice"); var names = await store.GetNamesAsync(10UL, serverId, []); @@ -250,7 +259,7 @@ public async Task Name_lookup_returns_empty_without_querying_for_no_ids() [Fact] public async Task Recording_a_name_twice_updates_rather_than_duplicating() { - var (store, context, conn) = Create(); + var (store, context, conn, _) = Create(); await using var _ = conn; await using var __ = context; var serverId = await SeedServerAsync(context); @@ -262,4 +271,23 @@ public async Task Recording_a_name_twice_updates_rather_than_duplicating() Assert.Equal("Alicia", names[111UL]); Assert.Single(await context.ClanPlayerNames.ToListAsync()); } + + [Fact] + public async Task Recording_the_same_name_again_skips_the_write() + { + var (store, context, conn, clock) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + await store.RecordNameAsync(10UL, serverId, 111UL, "Alice"); + var firstUpdatedUtc = (await context.ClanPlayerNames.SingleAsync(n => n.SteamId == 111UL)).UpdatedUtc; + + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch.AddMinutes(5)); + await store.RecordNameAsync(10UL, serverId, 111UL, "Alice"); + + var row = await context.ClanPlayerNames.SingleAsync(n => n.SteamId == 111UL); + Assert.Equal(firstUpdatedUtc, row.UpdatedUtc); + Assert.Single(await context.ClanPlayerNames.ToListAsync()); + } } From 0e8aeb3e2f6d572821cf47edba222b9a43d2d82f Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 23:58:07 +0200 Subject: [PATCH 11/39] Gate workspace channels on capabilities and support pinning Add a generic IWorkspaceCapabilityProvider seam so a ChannelSpec can name a capability: the reconciler provisions it only while available and deletes the channel and its anchored messages when it is not. A capability with no registered provider counts as unavailable. Also add MessageSpec.Pinned, pinned on first post only, and declare the two clan channels and three clan embeds. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Gateway/DiscordWorkspaceGateway.cs | 21 +++ .../Gateway/IWorkspaceGateway.cs | 7 + .../Locating/ClanChatChannelLocator.cs | 33 ++++ .../Reconciler/ServerInfoRefresher.cs | 3 + .../Reconciler/WorkspaceReconciler.cs | 52 ++++++ .../Registry/ChannelSpec.cs | 4 +- .../Registry/IWorkspaceCapabilityProvider.cs | 19 +++ .../Registry/IWorkspaceRegistry.cs | 15 ++ .../Registry/MessageSpec.cs | 4 +- .../Registry/WorkspaceRegistry.cs | 16 +- .../RustPlusBot.Features.Workspace.csproj | 1 + .../Specs/ServerWorkspaceSpecProvider.cs | 20 ++- .../WorkspaceKeys.cs | 22 +++ .../Workspace/IWorkspaceStore.cs | 10 ++ .../Workspace/WorkspaceStore.cs | 25 +++ .../CapabilityGatedChannelTests.cs | 158 ++++++++++++++++++ .../Fakes/FakeWorkspaceGateway.cs | 18 ++ .../Fakes/FakeWorkspaceStore.cs | 23 +++ .../Reconciler/ReconcilerHarness.cs | 67 ++++++-- .../Registry/WorkspaceRegistryTests.cs | 2 +- .../Workspace/WorkspaceStoreTests.cs | 43 +++++ 21 files changed, 544 insertions(+), 19 deletions(-) create mode 100644 src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs create mode 100644 src/RustPlusBot.Features.Workspace/Registry/IWorkspaceCapabilityProvider.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs index 837c2019..af5163f7 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs @@ -162,6 +162,27 @@ public async Task DeleteMessageAsync(ulong guildId, } } + /// + public async Task PinMessageAsync(ulong guildId, + ulong channelId, + ulong messageId, + CancellationToken cancellationToken) + { + var channel = client.GetGuild(guildId)?.GetTextChannel(channelId); + if (channel is null) + { + return; + } + + if (await channel.GetMessageAsync(messageId).ConfigureAwait(false) is IUserMessage message) + { + await message.PinAsync(new RequestOptions + { + CancelToken = cancellationToken + }).ConfigureAwait(false); + } + } + /// public async Task DeleteChannelAsync(ulong guildId, ulong channelId, CancellationToken cancellationToken) { diff --git a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs index aef06424..bb785389 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs @@ -105,6 +105,13 @@ Task EditMessageAsync(ulong guildId, /// Token to cancel the operation. Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken); + /// Pins a message. A no-op if it is already pinned or already gone. + /// The snowflake ID of the guild. + /// The snowflake ID of the channel containing the message. + /// The snowflake ID of the message to pin. + /// Token to cancel the operation. + Task PinMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken); + /// Deletes a channel by snowflake (no-op if already gone). /// The snowflake ID of the guild. /// The snowflake ID of the channel to delete. diff --git a/src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs new file mode 100644 index 00000000..6d720fe2 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Time; + +namespace RustPlusBot.Features.Workspace.Locating; + +/// +/// Resolves the #clanchat channel id for a (guild, server) and supports the reverse direction +/// (channel id → guild + server) used by the Discord message handler. +/// +/// Opens scopes for the scoped workspace store. +/// Drives the cache TTL. +internal sealed class ClanChatChannelLocator(IServiceScopeFactory scopeFactory, IClock clock) + : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerClanChat) +{ + /// Resolves the (guild, server) pair a #clanchat channel belongs to, or null. + /// The Discord channel snowflake. + /// A cancellation token. + /// The owning guild and server, or null when the channel is not a provisioned #clanchat. + public async Task<(ulong GuildId, Guid ServerId)?> ResolveAsync(ulong channelId, + CancellationToken cancellationToken) + { + await EnsureFreshAsync(cancellationToken).ConfigureAwait(false); + foreach (var (key, value) in Entries) + { + if (value == channelId) + { + return key; + } + } + + return null; + } +} diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs b/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs index 4ac40a67..ba7bef60 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs @@ -24,6 +24,9 @@ internal sealed class ServerInfoRefresher( WorkspaceMessageKeys.ServerInfo, WorkspaceMessageKeys.ServerEvents, WorkspaceMessageKeys.ServerTeam, + WorkspaceMessageKeys.ClanOverview, + WorkspaceMessageKeys.ClanRoster, + WorkspaceMessageKeys.ClanInvites, ]; private readonly Dictionary _renderers = diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs index 4e842de0..2d5736c5 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs @@ -171,9 +171,19 @@ private async Task> EnsureChannelsAsync(ulong guildId, .ToDictionary(c => c.ChannelKey, StringComparer.Ordinal); var result = new Dictionary(StringComparer.Ordinal); var specs = backends.Registry.GetChannelSpecs(scope); + var gatedOff = new List(); foreach (var spec in specs) { + if (spec.Capability is { } capability && + !await backends.Registry + .IsCapabilityAvailableAsync(capability, guildId, serverId, cancellationToken) + .ConfigureAwait(false)) + { + gatedOff.Add(spec.Key); + continue; + } + var name = localizer.Get(spec.NameKey, culture); ulong channelId; @@ -214,6 +224,28 @@ await backends.Store.SaveChannelAsync( result[spec.Key] = channelId; } + // A capability that has gone away is an explicit removal, distinct from a spec merely + // disappearing from the registry (which is retained, below). + foreach (var key in gatedOff) + { + if (!existing.TryGetValue(key, out var stale)) + { + continue; + } + + await backends.Gateway.DeleteChannelAsync(guildId, stale.DiscordChannelId, cancellationToken) + .ConfigureAwait(false); + await backends.Store.DeleteChannelAsync(guildId, serverId, key, cancellationToken).ConfigureAwait(false); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Removed channel '{Key}' for guild {GuildId}: its capability is no longer available.", key, + guildId); + } + } + + // Gated-off keys are still in specs, so the retention log below never reports a channel this + // pass deliberately removed. var registryKeys = specs.Select(s => s.Key).ToHashSet(StringComparer.Ordinal); if (logger.IsEnabled(LogLevel.Information)) { @@ -302,6 +334,7 @@ await backends.Gateway.DeleteMessageAsync(guildId, channelId, staleId, cancellat } ulong messageId; + var newlyPosted = false; if (item.LiveId is { } liveId) { await backends.Gateway @@ -314,6 +347,25 @@ await backends.Gateway messageId = await backends.Gateway .PostMessageAsync(guildId, channelId, item.Payload, cancellationToken) .ConfigureAwait(false); + newlyPosted = true; + } + + // Pin only on first post: pinning is idempotent but costs an API call, and a + // re-posted message (declaration-order repair) also lands here as newly posted. + if (newlyPosted && item.Spec.Pinned) + { + try + { + await backends.Gateway.PinMessageAsync(guildId, channelId, messageId, cancellationToken) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // Broad catch: an unpinned embed is still correct; never fail a reconcile over it. + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) +#pragma warning restore CA1031 + { + logger.LogWarning(ex, "Pinning message '{Key}' in guild {GuildId} failed.", item.Spec.Key, + guildId); + } } await backends.Store.SaveMessageAsync( diff --git a/src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs b/src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs index 37fc7283..c86a26f7 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs @@ -6,9 +6,11 @@ namespace RustPlusBot.Features.Workspace.Registry; /// i18n key resolving to the channel name. /// Permission profile to apply. /// Sort order within the category. +/// Optional capability gating this channel; null means always provisioned. internal sealed record ChannelSpec( WorkspaceScope Scope, string Key, string NameKey, ChannelPermissionProfile Permissions, - int Order); + int Order, + string? Capability = null); diff --git a/src/RustPlusBot.Features.Workspace/Registry/IWorkspaceCapabilityProvider.cs b/src/RustPlusBot.Features.Workspace/Registry/IWorkspaceCapabilityProvider.cs new file mode 100644 index 00000000..3e6b33e0 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Registry/IWorkspaceCapabilityProvider.cs @@ -0,0 +1,19 @@ +namespace RustPlusBot.Features.Workspace.Registry; + +/// +/// Answers whether an optional workspace capability currently applies to a scope. A +/// naming a capability is provisioned only while its provider reports +/// available, and its channel is deleted when it does not. +/// +internal interface IWorkspaceCapabilityProvider +{ + /// The capability name this provider answers for (matches ). + string Capability { get; } + + /// Reports whether the capability currently applies to the given scope. + /// The guild snowflake. + /// The server id, or null for the global scope. + /// A cancellation token. + /// True when the capability's channels should exist. + ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Workspace/Registry/IWorkspaceRegistry.cs b/src/RustPlusBot.Features.Workspace/Registry/IWorkspaceRegistry.cs index 8fb1644a..e0f384de 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/IWorkspaceRegistry.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/IWorkspaceRegistry.cs @@ -12,4 +12,19 @@ internal interface IWorkspaceRegistry /// The scope to filter by. /// The message specs. IReadOnlyList GetMessageSpecs(WorkspaceScope scope); + + /// + /// Reports whether a named capability currently applies. An unknown capability — one with no + /// registered provider — is unavailable, so a feature the host did not compose leaves no + /// orphaned channels behind. + /// + /// The capability name. + /// The guild snowflake. + /// The server id, or null for the global scope. + /// A cancellation token. + /// True when the capability's channels should exist. + ValueTask IsCapabilityAvailableAsync(string capability, + ulong guildId, + Guid? serverId, + CancellationToken cancellationToken); } diff --git a/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs b/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs index ac690ba1..1f79459e 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs @@ -4,4 +4,6 @@ namespace RustPlusBot.Features.Workspace.Registry; /// Global or per-server. /// Stable key (persisted as MessageKey); also the renderer lookup key. /// The of the channel it lives in. -internal sealed record MessageSpec(WorkspaceScope Scope, string Key, string ChannelKey); +/// True to pin the message when it is first posted, so a channel that also +/// carries transient messages keeps this one reachable from the pin bar. +internal sealed record MessageSpec(WorkspaceScope Scope, string Key, string ChannelKey, bool Pinned = false); diff --git a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs index dd3d8732..740a05a7 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs @@ -3,13 +3,27 @@ namespace RustPlusBot.Features.Workspace.Registry; /// Aggregates contributed spec providers into ordered, scope-filtered views. /// All channel spec providers. /// All message spec providers. +/// All capability providers, indexed by capability name. internal sealed class WorkspaceRegistry( IEnumerable channelProviders, - IEnumerable messageProviders) : IWorkspaceRegistry + IEnumerable messageProviders, + IEnumerable capabilityProviders) : IWorkspaceRegistry { private readonly List _channels = [.. channelProviders.SelectMany(p => p.GetChannelSpecs())]; private readonly List _messages = [.. messageProviders.SelectMany(p => p.GetMessageSpecs())]; + private readonly Dictionary _capabilities = + capabilityProviders.ToDictionary(p => p.Capability, StringComparer.Ordinal); + + /// + public ValueTask IsCapabilityAvailableAsync(string capability, + ulong guildId, + Guid? serverId, + CancellationToken cancellationToken) => + _capabilities.TryGetValue(capability, out var provider) + ? provider.IsAvailableAsync(guildId, serverId, cancellationToken) + : ValueTask.FromResult(false); + /// public IReadOnlyList GetChannelSpecs(WorkspaceScope scope) => [.. _channels.Where(s => s.Scope == scope).OrderBy(s => s.Order).ThenBy(s => s.Key, StringComparer.Ordinal)]; diff --git a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj index 5fff1dcf..b78b72bd 100644 --- a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj +++ b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj @@ -2,6 +2,7 @@ + diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs index ca22ea62..152bdd59 100644 --- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs +++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs @@ -12,16 +12,20 @@ public IEnumerable GetChannelSpecs() => ChannelPermissionProfile.ReadOnly, 0), new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerTeamChat, "channel.teamchat.name", ChannelPermissionProfile.Interactive, 1), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerClanChat, "channel.clanchat.name", + ChannelPermissionProfile.Interactive, 2, WorkspaceCapabilities.Clan), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerClanInfo, "channel.claninfo.name", + ChannelPermissionProfile.ReadOnly, 3, WorkspaceCapabilities.Clan), new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerEvents, "channel.events.name", - ChannelPermissionProfile.ReadOnly, 2), + ChannelPermissionProfile.ReadOnly, 4), new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerMap, "channel.map.name", - ChannelPermissionProfile.ReadOnly, 3), + ChannelPermissionProfile.ReadOnly, 5), new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerSwitches, "channel.switches.name", - ChannelPermissionProfile.Interactive, 4), + ChannelPermissionProfile.Interactive, 6), new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerAlarms, "channel.alarms.name", - ChannelPermissionProfile.Interactive, 5), + ChannelPermissionProfile.Interactive, 7), new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerStorageMonitors, "channel.storagemonitors.name", - ChannelPermissionProfile.Interactive, 6), + ChannelPermissionProfile.Interactive, 8), ]; /// @@ -34,5 +38,11 @@ public IEnumerable GetMessageSpecs() => new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerEvents, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerTeam, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerMap, WorkspaceChannelKeys.ServerMap), + + // #claninfo also carries a transient change feed, so the anchored embeds are pinned to stay + // reachable once the feed pushes them up. + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanOverview, WorkspaceChannelKeys.ServerClanInfo, true), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanRoster, WorkspaceChannelKeys.ServerClanInfo, true), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanInvites, WorkspaceChannelKeys.ServerClanInfo, true), ]; } diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs index 9653bac7..5a23f5cb 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs @@ -18,6 +18,12 @@ internal static class WorkspaceChannelKeys /// Key for the per-server #teamchat channel. public const string ServerTeamChat = "teamchat"; + /// Key for the per-server #clanchat channel (provisioned only while a clan exists). + public const string ServerClanChat = "clanchat"; + + /// Key for the per-server #claninfo channel (provisioned only while a clan exists). + public const string ServerClanInfo = "claninfo"; + /// Key for the per-server #events channel. public const string ServerEvents = "events"; @@ -60,4 +66,20 @@ internal static class WorkspaceMessageKeys /// Key for the per-server map control message (layer toggles). public const string ServerMap = "server.map"; + + /// Key for the pinned clan overview embed. Rendered by Features.Clans. + public const string ClanOverview = "clan.overview"; + + /// Key for the pinned clan roster embed. Rendered by Features.Clans. + public const string ClanRoster = "clan.roster"; + + /// Key for the pinned clan invites embed. Rendered by Features.Clans. + public const string ClanInvites = "clan.invites"; +} + +/// Stable capability names gating optional workspace channels. +internal static class WorkspaceCapabilities +{ + /// Gates the per-server clan channels; available only while the paired player is in a clan. + public const string Clan = "clan"; } diff --git a/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs b/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs index 09ef5408..19017906 100644 --- a/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs +++ b/src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs @@ -74,6 +74,16 @@ Task> GetChannelsAsync(ulong guildId, /// A task that completes when the flag has been persisted. Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, CancellationToken cancellationToken = default); + /// Deletes one provisioned channel row and any messages anchored in it. + /// The Discord guild snowflake. + /// The Rust server id, or null for the global scope. + /// The stable channel key to delete. + /// A cancellation token. + Task DeleteChannelAsync(ulong guildId, + Guid? serverId, + string channelKey, + 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 835e1cbb..05f19487 100644 --- a/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs +++ b/src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs @@ -172,6 +172,31 @@ public async Task SetPingEveryoneOnWipeAsync(ulong guildId, await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } + /// + public async Task DeleteChannelAsync(ulong guildId, + Guid? serverId, + string channelKey, + CancellationToken cancellationToken = default) + { + var channel = await context.ProvisionedChannels + .SingleOrDefaultAsync(c => c.GuildId == guildId && c.RustServerId == serverId && c.ChannelKey == channelKey, + cancellationToken) + .ConfigureAwait(false); + if (channel is null) + { + return; + } + + // A message row left pointing at a deleted channel would make the next reconcile try to edit + // a message in a channel that no longer exists. + await context.ProvisionedMessages + .Where(m => m.GuildId == guildId && m.RustServerId == serverId && + m.DiscordChannelId == channel.DiscordChannelId) + .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + context.ProvisionedChannels.Remove(channel); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + /// public async Task DeleteScopeAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken = default) { diff --git a/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs new file mode 100644 index 00000000..36521e76 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs @@ -0,0 +1,158 @@ +using NSubstitute; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Features.Workspace.Tests.Reconciler; + +namespace RustPlusBot.Features.Workspace.Tests; + +public sealed class CapabilityGatedChannelTests +{ + private static readonly Guid ServerId = Guid.Parse("8f0f0d3e-2b16-4a3e-8f5a-1d1c9f2a7b41"); + + [Fact] + public async Task Creates_a_capability_gated_channel_when_the_capability_is_available() + { + var harness = GatedHarness(available: true); + var sut = harness.Build(); + + await sut.ReconcileServerAsync(1, ServerId); + + var channels = await harness.Store.GetChannelsAsync(1, ServerId); + Assert.Contains(channels, c => c.ChannelKey == "clanchat"); + var record = channels.Single(c => c.ChannelKey == "clanchat"); + Assert.True(harness.Gateway.ChannelExists(1, record.DiscordChannelId)); + } + + [Fact] + public async Task Skips_and_deletes_a_capability_gated_channel_when_unavailable() + { + var harness = GatedHarness(available: true); + await harness.Build().ReconcileServerAsync(1, ServerId); + var provisioned = (await harness.Store.GetChannelsAsync(1, ServerId)).Single(c => c.ChannelKey == "clanchat"); + + // The capability goes away: same store + gateway, provider now reports unavailable. + var sut = new ReconcilerBuilderReusing(harness) + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") + .WithChannel(WorkspaceScope.PerServer, "clanchat", "channel.teamchat.name", 1, "clan") + .WithCapability("clan", available: false) + .Build(); + + await sut.ReconcileServerAsync(1, ServerId); + + Assert.False(harness.Gateway.ChannelExists(1, provisioned.DiscordChannelId)); + Assert.DoesNotContain(await harness.Store.GetChannelsAsync(1, ServerId), c => c.ChannelKey == "clanchat"); + Assert.Contains(await harness.Store.GetChannelsAsync(1, ServerId), c => c.ChannelKey == "info"); + } + + [Fact] + public async Task Does_not_create_a_capability_gated_channel_that_was_never_provisioned() + { + var harness = GatedHarness(available: false); + var sut = harness.Build(); + + await sut.ReconcileServerAsync(1, ServerId); + + var channels = await harness.Store.GetChannelsAsync(1, ServerId); + Assert.DoesNotContain(channels, c => c.ChannelKey == "clanchat"); + Assert.Equal(1, harness.Gateway.CreatedChannels); // only the ungated #info channel + } + + [Fact] + public async Task Treats_a_capability_with_no_registered_provider_as_unavailable() + { + var harness = NewHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") + .WithChannel(WorkspaceScope.PerServer, "ghostly", "channel.teamchat.name", 1, "ghost"); + var sut = harness.Build(); + + await sut.ReconcileServerAsync(1, ServerId); + + Assert.DoesNotContain(await harness.Store.GetChannelsAsync(1, ServerId), c => c.ChannelKey == "ghostly"); + Assert.Equal(1, harness.Gateway.CreatedChannels); + } + + [Fact] + public async Task Leaves_ungated_channels_untouched() + { + var harness = GatedHarness(available: false); + var sut = harness.Build(); + + await sut.ReconcileServerAsync(1, ServerId); + + var channels = await harness.Store.GetChannelsAsync(1, ServerId); + var info = Assert.Single(channels); + Assert.Equal("info", info.ChannelKey); + Assert.True(harness.Gateway.ChannelExists(1, info.DiscordChannelId)); + } + + [Fact] + public async Task Pins_a_pinned_message_only_when_it_is_newly_posted() + { + var harness = NewHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") + .WithMessage(WorkspaceScope.PerServer, "clan.overview", "info", "overview", pinned: true); + var sut = harness.Build(); + + await sut.ReconcileServerAsync(1, ServerId); + + var (pinnedChannelId, pinnedMessageId) = Assert.Single(harness.Gateway.PinnedMessages); + var record = await harness.Store.GetMessageAsync(1, ServerId, "clan.overview"); + Assert.NotNull(record); + Assert.Equal(record!.DiscordMessageId, pinnedMessageId); + Assert.Equal(record.DiscordChannelId, pinnedChannelId); + + // Second reconcile: the message is still live, so it is edited — and not pinned again. + await sut.ReconcileServerAsync(1, ServerId); + + Assert.Single(harness.Gateway.PinnedMessages); + Assert.Equal(1, harness.Gateway.EditedMessages); + } + + [Fact] + public async Task Does_not_pin_messages_that_are_not_marked_pinned() + { + var harness = NewHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") + .WithMessage(WorkspaceScope.PerServer, "server.info", "info", "info-text"); + var sut = harness.Build(); + + await sut.ReconcileServerAsync(1, ServerId); + + Assert.Empty(harness.Gateway.PinnedMessages); + } + + [Fact] + public async Task A_failed_pin_does_not_fail_the_reconcile() + { + var harness = NewHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") + .WithMessage(WorkspaceScope.PerServer, "clan.overview", "info", "overview", pinned: true); + harness.Gateway.ThrowOnPin = true; + var sut = harness.Build(); + + await sut.ReconcileServerAsync(1, ServerId); + + Assert.Empty(harness.Gateway.PinnedMessages); + Assert.NotNull(await harness.Store.GetMessageAsync(1, ServerId, "clan.overview")); + } + + private static ReconcilerHarness GatedHarness(bool available) => NewHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") + .WithChannel(WorkspaceScope.PerServer, "clanchat", "channel.teamchat.name", 1, "clan") + .WithCapability("clan", available); + + private static ReconcilerHarness NewHarness() + { + var harness = new ReconcilerHarness(); + harness.Servers.GetAsync(1, ServerId, Arg.Any()) + .Returns(new RustServer + { + Id = ServerId, + GuildId = 1, + Name = "Rustopia EU", + Ip = "1.1.1.1", + Port = 28015 + }); + return harness; + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs index b52de9f2..d487f975 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs @@ -11,10 +11,14 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway private readonly ConcurrentDictionary _channels = new(); private readonly List _deletedMessageIds = []; private readonly ConcurrentDictionary _messages = new(); + private readonly List<(ulong ChannelId, ulong MessageId)> _pinnedMessages = []; private readonly List _postedPayloads = []; private ulong _nextId = 1000; public IReadOnlyList MissingPermissions { get; set; } = []; + + /// When true, throws (pin-failure path). + public bool ThrowOnPin { get; set; } public int CreatedCategories { get; private set; } public int CreatedChannels { get; private set; } public int PostedMessages { get; private set; } @@ -28,6 +32,9 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway /// Payloads posted via , in call order. public IReadOnlyList PostedPayloads => _postedPayloads; + /// Pairs pinned via , in call order. + public IReadOnlyList<(ulong ChannelId, ulong MessageId)> PinnedMessages => _pinnedMessages; + public bool CategoryExists(ulong guildId, ulong categoryId) => _categories.ContainsKey(categoryId); public Task FindCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken) @@ -121,6 +128,17 @@ public Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, return Task.CompletedTask; } + public Task PinMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken) + { + if (ThrowOnPin) + { + throw new InvalidOperationException($"Pinning message {messageId} failed."); + } + + _pinnedMessages.Add((channelId, messageId)); + return Task.CompletedTask; + } + public Task DeleteChannelAsync(ulong guildId, ulong channelId, CancellationToken cancellationToken) { _channels.TryRemove(channelId, out _); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs index 66aeb62f..a1a27809 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs @@ -89,6 +89,29 @@ public Task SetPingEveryoneOnWipeAsync(ulong guildId, bool enabled, Cancellation return Task.CompletedTask; } + public Task DeleteChannelAsync(ulong guildId, + Guid? serverId, + string channelKey, + CancellationToken cancellationToken = default) + { + if (!_channels.TryRemove($"{Scope(guildId, serverId)}|{channelKey}", out var channel)) + { + return Task.CompletedTask; + } + + var prefix = Scope(guildId, serverId) + "|"; + foreach (var key in _messages + .Where(kv => kv.Key.StartsWith(prefix, StringComparison.Ordinal) && + kv.Value.DiscordChannelId == channel.DiscordChannelId) + .Select(kv => kv.Key) + .ToList()) + { + _messages.TryRemove(key, out _); + } + + 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/Reconciler/ReconcilerHarness.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs index 6d639b2d..5a5c5123 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs @@ -12,6 +12,7 @@ namespace RustPlusBot.Features.Workspace.Tests.Reconciler; /// Builds a WorkspaceReconciler wired with fakes for tests. internal sealed class ReconcilerHarness { + private readonly List _capabilityProviders = []; private readonly List _channelProviders = []; private readonly List _messageProviders = []; private readonly List _renderers = []; @@ -19,24 +20,38 @@ internal sealed class ReconcilerHarness public FakeWorkspaceStore Store { get; } = new(); public IServerService Servers { get; } = Substitute.For(); - public ReconcilerHarness WithChannel(WorkspaceScope scope, string key, string nameKey, int order = 0) + public ReconcilerHarness WithChannel(WorkspaceScope scope, + string key, + string nameKey, + int order = 0, + string? capability = null) { _channelProviders.Add(new StubChannelProvider([ - new ChannelSpec(scope, key, nameKey, ChannelPermissionProfile.ReadOnly, order) + new ChannelSpec(scope, key, nameKey, ChannelPermissionProfile.ReadOnly, order, capability) ])); return this; } - public ReconcilerHarness WithMessage(WorkspaceScope scope, string key, string channelKey, string text) + public ReconcilerHarness WithMessage(WorkspaceScope scope, + string key, + string channelKey, + string text, + bool pinned = false) { - _messageProviders.Add(new StubMessageProvider([new MessageSpec(scope, key, channelKey)])); + _messageProviders.Add(new StubMessageProvider([new MessageSpec(scope, key, channelKey, pinned)])); _renderers.Add(new StubRenderer(key, text)); return this; } + public ReconcilerHarness WithCapability(string capability, bool available) + { + _capabilityProviders.Add(new StubCapabilityProvider(capability, available)); + return this; + } + public WorkspaceReconciler Build() { - var registry = new WorkspaceRegistry(_channelProviders, _messageProviders); + var registry = new WorkspaceRegistry(_channelProviders, _messageProviders, _capabilityProviders); return new WorkspaceReconciler( new WorkspaceBackends(registry, Gateway, Store), _renderers, Servers, new ResxLocalizer(), new ProvisioningLock(), @@ -61,31 +76,55 @@ public ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) => ValueTask.FromResult(new MessagePayload(text, null, null)); } + + private sealed class StubCapabilityProvider(string capability, bool available) : IWorkspaceCapabilityProvider + { + public string Capability { get; } = capability; + + public ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken) => + ValueTask.FromResult(available); + } } internal sealed class ReconcilerBuilderReusing(ReconcilerHarness source) { + private readonly List _capabilityProviders = []; private readonly List _channelProviders = []; private readonly List _messageProviders = []; private readonly List _renderers = []; - public ReconcilerBuilderReusing WithChannel(WorkspaceScope scope, string key, string nameKey, int order = 0) + public ReconcilerBuilderReusing WithChannel(WorkspaceScope scope, + string key, + string nameKey, + int order = 0, + string? capability = null) { _channelProviders.Add(new ListChannelProvider([ - new ChannelSpec(scope, key, nameKey, ChannelPermissionProfile.ReadOnly, order) + new ChannelSpec(scope, key, nameKey, ChannelPermissionProfile.ReadOnly, order, capability) ])); return this; } - public ReconcilerBuilderReusing WithMessage(WorkspaceScope scope, string key, string channelKey, string text) + public ReconcilerBuilderReusing WithMessage(WorkspaceScope scope, + string key, + string channelKey, + string text, + bool pinned = false) { - _messageProviders.Add(new ListMessageProvider([new MessageSpec(scope, key, channelKey)])); + _messageProviders.Add(new ListMessageProvider([new MessageSpec(scope, key, channelKey, pinned)])); _renderers.Add(new ListMessageRenderer(key, text)); return this; } + public ReconcilerBuilderReusing WithCapability(string capability, bool available) + { + _capabilityProviders.Add(new ListCapabilityProvider(capability, available)); + return this; + } + public WorkspaceReconciler Build() => new( - new WorkspaceBackends(new WorkspaceRegistry(_channelProviders, _messageProviders), source.Gateway, + new WorkspaceBackends(new WorkspaceRegistry(_channelProviders, _messageProviders, _capabilityProviders), + source.Gateway, source.Store), _renderers, source.Servers, new ResxLocalizer(), new ProvisioningLock(), @@ -109,4 +148,12 @@ public ValueTask RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) => ValueTask.FromResult(new MessagePayload(text, null, null)); } + + private sealed class ListCapabilityProvider(string capability, bool available) : IWorkspaceCapabilityProvider + { + public string Capability { get; } = capability; + + public ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken) => + ValueTask.FromResult(available); + } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs index 591dd3aa..2ade435c 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs @@ -7,7 +7,7 @@ public sealed class WorkspaceRegistryTests [Fact] public void ChannelSpecs_AggregateAcrossProviders_OrderedByOrder() { - var registry = new WorkspaceRegistry([new ProviderA(), new ProviderB()], []); + var registry = new WorkspaceRegistry([new ProviderA(), new ProviderB()], [], []); var global = registry.GetChannelSpecs(WorkspaceScope.Global); Assert.Equal(["a", "b"], global.Select(s => s.Key)); diff --git a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs index 74f8cd1b..f533f34d 100644 --- a/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs @@ -127,6 +127,49 @@ await store.SaveCategoryAsync(new ProvisionedCategory Assert.NotNull(await store.GetCategoryAsync(1, server.Id)); } + [Fact] + public async Task Deleting_a_channel_also_removes_its_anchored_messages() + { + var store = NewStore(out _, out var cleanup); + using var _cleanup = cleanup; + + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 1, RustServerId = null, ChannelKey = "claninfo", DiscordChannelId = 5 + }); + await store.SaveChannelAsync(new ProvisionedChannel + { + GuildId = 1, RustServerId = null, ChannelKey = "information", DiscordChannelId = 6 + }); + await store.SaveMessageAsync(new ProvisionedMessage + { + GuildId = 1, MessageKey = "clan.overview", DiscordChannelId = 5, DiscordMessageId = 100 + }); + await store.SaveMessageAsync(new ProvisionedMessage + { + GuildId = 1, MessageKey = "information.main", DiscordChannelId = 6, DiscordMessageId = 101 + }); + + await store.DeleteChannelAsync(1, null, "claninfo"); + + var channels = await store.GetChannelsAsync(1, null); + Assert.Single(channels); + Assert.Equal("information", channels[0].ChannelKey); + Assert.Null(await store.GetMessageAsync(1, null, "clan.overview")); + Assert.NotNull(await store.GetMessageAsync(1, null, "information.main")); + } + + [Fact] + public async Task Deleting_an_unknown_channel_is_a_no_op() + { + var store = NewStore(out _, out var cleanup); + using var _cleanup = cleanup; + + await store.DeleteChannelAsync(1, null, "claninfo"); + + Assert.Empty(await store.GetChannelsAsync(1, null)); + } + private sealed class FixedClock(DateTimeOffset now) : IClock { public DateTimeOffset UtcNow { get; } = now; From 6b10a4e68a69106caab7ba8280e74618cf932575 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 21 Jul 2026 23:59:02 +0200 Subject: [PATCH 12/39] Carry Task 5 review concerns into the clan plan Task 12 must update the hard-coded key-count assertion in the localization parity test. Task 11's capability provider must never answer false on a transient failure, must register exactly one provider per name, and should cache within a reconcile pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-21-clan-support.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-07-21-clan-support.md b/docs/superpowers/plans/2026-07-21-clan-support.md index dca7810c..8d501dbd 100644 --- a/docs/superpowers/plans/2026-07-21-clan-support.md +++ b/docs/superpowers/plans/2026-07-21-clan-support.md @@ -3281,15 +3281,22 @@ Follow the file's existing `... Date: Tue, 21 Jul 2026 23:59:17 +0200 Subject: [PATCH 13/39] Note the capability provider constraints in the clan plan Task 5 already added the InternalsVisibleTo grant, and its review surfaced three rules the clan capability provider must follow: never answer false on a transient failure, register exactly once, and cache within a reconcile pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-21-clan-support.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-21-clan-support.md b/docs/superpowers/plans/2026-07-21-clan-support.md index 8d501dbd..92360ed9 100644 --- a/docs/superpowers/plans/2026-07-21-clan-support.md +++ b/docs/superpowers/plans/2026-07-21-clan-support.md @@ -3188,7 +3188,13 @@ internal sealed class ClanCapabilityProvider(IServiceScopeFactory scopeFactory) } ``` -`IWorkspaceCapabilityProvider` is `internal` to `Features.Workspace`, so add an `InternalsVisibleTo("RustPlusBot.Features.Clans")` to the Workspace project — matching how it already exposes internals to its test assembly. (If you prefer not to widen that, promote `IWorkspaceCapabilityProvider`, `ChannelSpec.Capability` and `WorkspaceCapabilities` to public instead; pick one and be consistent.) +`InternalsVisibleTo("RustPlusBot.Features.Clans")` was already added to the Workspace project in Task 5, so `IWorkspaceCapabilityProvider` is visible here. + +**Three constraints carried over from Task 5's review, all load-bearing:** + +1. **Never answer `false` on a transient failure.** A `false` answer deletes the Discord channel and all its history. Answering from `IClanStore.HasClanAsync` is safe because `ClanStateService` clears that row only on a *definitive* `NoClan`, never on `Unavailable`. Do **not** add a `try/catch` that swallows a store exception into `false` — the reconciler deliberately lets an exception propagate and fail the reconcile, which is the safe outcome. Failing loudly beats deleting a user's channels. +2. **Register exactly one provider per capability name.** `WorkspaceRegistry` indexes providers with `ToDictionary`, which throws at singleton construction on a duplicate key. Register `ClanCapabilityProvider` once. +3. **Cache within a reconcile pass.** Two gated specs mean two `IsAvailableAsync` calls per server per reconcile, and the heal path reconciles every server on a timer. Opening a DI scope and hitting the DB on each call is wasteful; use a short TTL cache (driven by `IClock`, as `CachingChannelLocator` does). Keep the TTL well under the reconcile interval so a clan transition is still picked up promptly. - [ ] **Step 7: Implement the hosted service** From 0557308ee6aafcb290fe6e5ab396b507e0267a12 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 00:05:33 +0200 Subject: [PATCH 14/39] Make the clan renderers' empty-payload contract explicit Task 5's review showed ServerInfoRefresher will full-reconcile on every tick for clanless servers unless the clan renderers return an empty payload when no clan is stored. Record that as a load-bearing requirement in Task 9. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-21-clan-support.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/superpowers/plans/2026-07-21-clan-support.md b/docs/superpowers/plans/2026-07-21-clan-support.md index 92360ed9..0170235e 100644 --- a/docs/superpowers/plans/2026-07-21-clan-support.md +++ b/docs/superpowers/plans/2026-07-21-clan-support.md @@ -2660,6 +2660,15 @@ EOF Renderers are `public` because `IMessageRenderer` is public and they are resolved by the Workspace reconciler across an assembly boundary — the same reason `ServerTeamMessageRenderer` is public. +**Load-bearing requirement — all three renderers.** Each renderer MUST return an empty +`MessagePayload(null, null, null)` when the server has no stored clan. This is not cosmetic: +`ServerInfoRefresher` now has the three clan keys in its `Keys` list, and its per-key flow is +"render → skip if empty → look up the `ProvisionedMessage` → **if missing, full-reconcile and +return**". A clanless server has no clan channel and therefore no clan `ProvisionedMessage`, so a +renderer that returns anything non-empty would trigger a full `ReconcileServerAsync` on *every* +refresh tick, forever, and would also stop the loop before it reached the later keys. The empty +payload is what makes the clan keys inert on clanless servers. + **Name resolution contract:** `ResolveAsync` returns a dictionary covering **every** requested id. Known ids map to the cached display name; unknown ids map to a Steam profile markdown link, `[{id}](https://steamcommunity.com/profiles/{id})`. Callers never have to null-check. From 4a8d0d6b86a60fdbfe419184044ccfc2407f5fd5 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 00:15:56 +0200 Subject: [PATCH 15/39] Unify the chat sender, locator and dedup seams across channel kinds Replace ITeamChatSender and ITeamChatChannelLocator with kind-parameterised IChatSender and IChatChannelLocator, and move RelayDedupBuffer to Abstractions keyed by channel kind so team and clan echoes cannot cross-cancel. One seam per concept, so the clan bridge does not need a parallel set. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Chat/ChatChannelKind.cs | 11 +++++ .../Chat}/RelayDedupBuffer.cs | 27 ++++++---- .../ChatServiceCollectionExtensions.cs | 1 + .../Inbound/TeamChatInboundProcessor.cs | 20 +++++--- .../Relaying/TeamChatRelay.cs | 12 +++-- .../ConnectionServiceCollectionExtensions.cs | 2 +- .../Listening/BotTeamChatSender.cs | 12 +++-- .../Listening/IBotTeamChatSender.cs | 4 +- .../Listening/IChatSender.cs | 39 +++++++++++++++ .../Listening/ITeamChatSender.cs | 29 ----------- .../Supervisor/ConnectionSupervisor.cs | 27 +++++++--- .../Locating/ClanChatChannelLocator.cs | 11 +++-- .../Locating/IChatChannelLocator.cs | 32 ++++++++++++ .../Locating/ITeamChatChannelLocator.cs | 18 ------- .../Locating/TeamChatChannelLocator.cs | 6 ++- .../WorkspaceServiceCollectionExtensions.cs | 3 +- .../AlarmStateRelayTests.cs | 2 +- .../Hosting/AlarmsHostedServiceTests.cs | 2 +- .../ChatRegistrationTests.cs | 14 +++--- .../Hosting/ChatHostedServiceTests.cs | 17 ++++--- .../RelayDedupBufferTests.cs | 49 ++++++++++++++----- .../TeamChatInboundProcessorTests.cs | 37 +++++++------- .../TeamChatRelayTests.cs | 9 ++-- .../Hosting/CommandsHostedServiceTests.cs | 2 +- .../BotTeamChatSenderTests.cs | 26 +++++----- .../ClanSupervisorTests.cs | 34 +++++++++++++ .../TeamChatSenderTests.cs | 10 ++-- .../Hosting/PlayersHostedServiceTests.cs | 2 +- .../WorkspaceRegistrationTests.cs | 2 +- 29 files changed, 301 insertions(+), 159 deletions(-) create mode 100644 src/RustPlusBot.Abstractions/Chat/ChatChannelKind.cs rename src/{RustPlusBot.Features.Chat/Relaying => RustPlusBot.Abstractions/Chat}/RelayDedupBuffer.cs (56%) create mode 100644 src/RustPlusBot.Features.Connections/Listening/IChatSender.cs delete mode 100644 src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs create mode 100644 src/RustPlusBot.Features.Workspace/Locating/IChatChannelLocator.cs delete mode 100644 src/RustPlusBot.Features.Workspace/Locating/ITeamChatChannelLocator.cs diff --git a/src/RustPlusBot.Abstractions/Chat/ChatChannelKind.cs b/src/RustPlusBot.Abstractions/Chat/ChatChannelKind.cs new file mode 100644 index 00000000..48560c09 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Chat/ChatChannelKind.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Abstractions.Chat; + +/// Which in-game chat channel a relayed line belongs to. +public enum ChatChannelKind +{ + /// In-game team chat. + Team = 0, + + /// In-game clan chat. + Clan = 1, +} diff --git a/src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs b/src/RustPlusBot.Abstractions/Chat/RelayDedupBuffer.cs similarity index 56% rename from src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs rename to src/RustPlusBot.Abstractions/Chat/RelayDedupBuffer.cs index 397901dd..2863fd12 100644 --- a/src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.cs +++ b/src/RustPlusBot.Abstractions/Chat/RelayDedupBuffer.cs @@ -1,25 +1,31 @@ using System.Collections.Concurrent; using RustPlusBot.Abstractions.Time; -namespace RustPlusBot.Features.Chat.Relaying; +namespace RustPlusBot.Abstractions.Chat; /// -/// Short-lived record of lines the bridge relayed into the game, keyed by (guild, server). When the bot's -/// active player echoes a relayed line back on the socket, matches and removes one -/// entry so the relay drops the echo instead of re-posting it to Discord. +/// Short-lived record of lines the bridge relayed into the game, keyed by (channel kind, guild, server). When +/// the bot's active player echoes a relayed line back on the socket, matches and +/// removes one entry so the relay drops the echo instead of re-posting it to Discord. The +/// is part of the key on purpose: the same text relayed to both team and clan +/// chat within the TTL produces one entry per channel, so a team echo cannot cancel the clan entry (or vice +/// versa) and leave the other channel's echo to be re-posted. /// /// Drives entry expiry. -internal sealed class RelayDedupBuffer(IClock clock) +public sealed class RelayDedupBuffer(IClock clock) { private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(15); - private readonly ConcurrentDictionary<(ulong Guild, Guid Server), List> _entries = new(); + + private readonly ConcurrentDictionary<(ChatChannelKind Kind, ulong Guild, Guid Server), List> _entries = + new(); /// Records that was relayed into the game for . + /// The chat channel the line was relayed to. /// The (guild, server) the line was relayed to. /// The exact formatted text that was sent. - public void Record((ulong Guild, Guid Server) key, string text) + public void Record(ChatChannelKind kind, (ulong Guild, Guid Server) key, string text) { - var list = _entries.GetOrAdd(key, _ => []); + var list = _entries.GetOrAdd((kind, key.Guild, key.Server), _ => []); lock (list) { Prune(list); @@ -28,12 +34,13 @@ public void Record((ulong Guild, Guid Server) key, string text) } /// Removes and returns true for the first live entry exactly matching . + /// The chat channel the echo arrived on. /// The (guild, server) the echo arrived on. /// The echoed text to match. /// True if a matching entry was consumed (the line is our own echo). - public bool TryConsume((ulong Guild, Guid Server) key, string text) + public bool TryConsume(ChatChannelKind kind, (ulong Guild, Guid Server) key, string text) { - if (!_entries.TryGetValue(key, out var list)) + if (!_entries.TryGetValue((kind, key.Guild, key.Server), out var list)) { return false; } diff --git a/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs index 2e9f2f7a..5c21337d 100644 --- a/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Features.Chat.Hosting; using RustPlusBot.Features.Chat.Inbound; using RustPlusBot.Features.Chat.Relaying; diff --git a/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs b/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs index 8b1e3852..77fba84a 100644 --- a/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs +++ b/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs @@ -1,6 +1,6 @@ using System.Globalization; using Microsoft.Extensions.DependencyInjection; -using RustPlusBot.Features.Chat.Relaying; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Persistence.Commands; @@ -8,16 +8,18 @@ namespace RustPlusBot.Features.Chat.Inbound; /// Turns a Discord #teamchat message into an in-game relay (record-then-send), reporting the outcome. -/// Resolves whether/which server a channel maps to. +/// The registered chat channel locators; the team one is selected from these. /// Relays the formatted line into the game. /// Records the relayed line so its in-game echo can be dropped. /// Opens a scope to read the scoped mute gate. internal sealed class TeamChatInboundProcessor( - ITeamChatChannelLocator locator, - ITeamChatSender sender, + IEnumerable locators, + IChatSender sender, RelayDedupBuffer dedup, IServiceScopeFactory scopeFactory) { + private readonly IChatChannelLocator _locator = locators.Single(l => l.Kind == ChatChannelKind.Team); + /// Processes one observed Discord message. /// The reduced message. /// A cancellation token. @@ -29,7 +31,7 @@ public async Task ProcessAsync(InboundMessage message, Cancellat return InboundOutcome.Ignored; } - var target = await locator.ResolveAsync(message.ChannelId, cancellationToken).ConfigureAwait(false); + var target = await _locator.ResolveAsync(message.ChannelId, cancellationToken).ConfigureAwait(false); if (target is not { } t) { return InboundOutcome.Ignored; @@ -51,9 +53,11 @@ public async Task ProcessAsync(InboundMessage message, Cancellat var text = string.Create(CultureInfo.InvariantCulture, $"[{message.DisplayName}] {message.Content}"); // Record BEFORE sending: the in-game echo can arrive before SendAsync returns. Unused entries expire. - dedup.Record(key, text); - var result = await sender.SendAsync(t.GuildId, t.ServerId, text, cancellationToken).ConfigureAwait(false); + dedup.Record(ChatChannelKind.Team, key, text); + var result = await sender + .SendAsync(ChatChannelKind.Team, t.GuildId, t.ServerId, text, cancellationToken) + .ConfigureAwait(false); - return result == TeamChatSendResult.Sent ? InboundOutcome.Sent : InboundOutcome.Failed; + return result == ChatSendResult.Sent ? InboundOutcome.Sent : InboundOutcome.Failed; } } diff --git a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs index a640afb8..45c1d413 100644 --- a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs +++ b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Chat.Webhooks; using RustPlusBot.Features.Connections.Listening; @@ -11,16 +12,18 @@ namespace RustPlusBot.Features.Chat.Relaying; /// Relays one received in-game team message into its Discord #teamchat channel, dropping our own /// echoes and command invocations. /// -/// Resolves the target #teamchat channel. +/// The registered chat channel locators; the team one is selected from these. /// Posts the line via webhook. /// Tracks lines the bridge relayed into the game so their echoes can be dropped. /// Opens a scope to read the scoped command prefix. internal sealed class TeamChatRelay( - ITeamChatChannelLocator locator, + IEnumerable locators, ITeamChatWebhookPoster poster, RelayDedupBuffer dedup, IServiceScopeFactory scopeFactory) { + private readonly IChatChannelLocator _locator = locators.Single(l => l.Kind == ChatChannelKind.Team); + /// Handles one . /// The received team message. /// A cancellation token. @@ -32,14 +35,15 @@ public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken can return; // Bot-originated line echoing back; #teamchat carries only human discussion. } - if (evt.FromActivePlayer && dedup.TryConsume((evt.GuildId, evt.ServerId), evt.Message)) + if (evt.FromActivePlayer && + dedup.TryConsume(ChatChannelKind.Team, (evt.GuildId, evt.ServerId), evt.Message)) { return; // Our own relayed line echoing back; do not re-post. } // The locator is an in-memory cache, so resolve the channel first: unmapped servers exit // before the per-message prefix query below. - var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + var channelId = await _locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); if (channelId is null) { diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs index 641fc0c3..3955392b 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs @@ -25,7 +25,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/RustPlusBot.Features.Connections/Listening/BotTeamChatSender.cs b/src/RustPlusBot.Features.Connections/Listening/BotTeamChatSender.cs index 150b58d9..685c34ff 100644 --- a/src/RustPlusBot.Features.Connections/Listening/BotTeamChatSender.cs +++ b/src/RustPlusBot.Features.Connections/Listening/BotTeamChatSender.cs @@ -1,17 +1,19 @@ using System.Globalization; +using RustPlusBot.Abstractions.Chat; namespace RustPlusBot.Features.Connections.Listening; -/// Prepends and forwards to the raw . -/// The raw team-chat sender. -internal sealed class BotTeamChatSender(ITeamChatSender inner) : IBotTeamChatSender +/// Prepends and forwards to the raw . +/// The raw chat sender. +internal sealed class BotTeamChatSender(IChatSender inner) : IBotTeamChatSender { /// - public Task SendAsync(ulong guildId, + public Task SendAsync(ulong guildId, Guid serverId, string message, CancellationToken cancellationToken) => - inner.SendAsync(guildId, + inner.SendAsync(ChatChannelKind.Team, + guildId, serverId, string.Create(CultureInfo.InvariantCulture, $"{BotTeamChat.Prefix} {message}"), cancellationToken); diff --git a/src/RustPlusBot.Features.Connections/Listening/IBotTeamChatSender.cs b/src/RustPlusBot.Features.Connections/Listening/IBotTeamChatSender.cs index 5edb6c56..88a1b9f3 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IBotTeamChatSender.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IBotTeamChatSender.cs @@ -4,7 +4,7 @@ namespace RustPlusBot.Features.Connections.Listening; /// Relays a bot-originated line (command reply, event/player/alarm notification) into a server's /// in-game team chat, prefixed with so its echo is never re-posted /// to the Discord #teamchat channel. Player speech bridged from Discord uses -/// instead. +/// instead. /// public interface IBotTeamChatSender { @@ -14,7 +14,7 @@ public interface IBotTeamChatSender /// The unprefixed message text. /// A cancellation token. /// The send result. - Task SendAsync(ulong guildId, + Task SendAsync(ulong guildId, Guid serverId, string message, CancellationToken cancellationToken); diff --git a/src/RustPlusBot.Features.Connections/Listening/IChatSender.cs b/src/RustPlusBot.Features.Connections/Listening/IChatSender.cs new file mode 100644 index 00000000..4bb2dd2d --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/IChatSender.cs @@ -0,0 +1,39 @@ +using RustPlusBot.Abstractions.Chat; + +namespace RustPlusBot.Features.Connections.Listening; + +/// The outcome of relaying a Discord message into an in-game chat channel. +public enum ChatSendResult +{ + /// The message was handed to the live socket. + Sent = 0, + + /// There is no live socket for that (guild, server) right now. + NotConnected = 1, + + /// A live socket exists but the send failed. + Failed = 2, +} + +/// +/// Relays a message into one of a server's in-game chat channels (implemented by the connection supervisor). +/// The selects the destination, so one seam serves both team and clan chat. +/// +public interface IChatSender +{ + /// + /// Sends to the chat channel on the live socket for + /// (, ). + /// + /// The in-game chat channel to send to. + /// The owning guild snowflake. + /// The target server id. + /// The message text to relay. + /// A cancellation token. + /// The send result. + Task SendAsync(ChatChannelKind kind, + ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs b/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs deleted file mode 100644 index 283dccd7..00000000 --- a/src/RustPlusBot.Features.Connections/Listening/ITeamChatSender.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace RustPlusBot.Features.Connections.Listening; - -/// The outcome of relaying a Discord message into in-game team chat. -public enum TeamChatSendResult -{ - /// The message was handed to the live socket. - Sent = 0, - - /// There is no live socket for that (guild, server) right now. - NotConnected = 1, - - /// A live socket exists but the send failed. - Failed = 2, -} - -/// Relays a message into a server's in-game team chat (implemented by the connection supervisor). -public interface ITeamChatSender -{ - /// Sends to the live socket for (, ). - /// The owning guild snowflake. - /// The target server id. - /// The message text to relay. - /// A cancellation token. - /// The send result. - Task SendAsync(ulong guildId, - Guid serverId, - string message, - CancellationToken cancellationToken); -} diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 99020693..7cf29759 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -40,7 +41,7 @@ internal sealed partial class ConnectionSupervisor( IClock clock, IOptions options, ILogger logger) - : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAfkState, IAsyncDisposable + : IConnectionSupervisor, IChatSender, IRustServerQuery, IAfkState, IAsyncDisposable { private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Handle> _connections = new(); private readonly SemaphoreSlim _gate = new(1, 1); @@ -76,7 +77,7 @@ internal sealed partial class ConnectionSupervisor( public async ValueTask DisposeAsync() { // The supervisor is registered as one singleton backing three service types (IConnectionSupervisor, - // ITeamChatSender, and the concrete type), so the DI container may invoke DisposeAsync more than once. + // IChatSender, and the concrete type), so the DI container may invoke DisposeAsync more than once. if (_disposed) { return; @@ -363,7 +364,8 @@ public async Task StrobeSmartSwitchAsync( } /// - public async Task SendAsync( + public async Task SendAsync( + ChatChannelKind kind, ulong guildId, Guid serverId, string message, @@ -371,13 +373,24 @@ public async Task SendAsync( { if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) { - return TeamChatSendResult.NotConnected; + return ChatSendResult.NotConnected; } try { - await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); - return TeamChatSendResult.Sent; + switch (kind) + { + case ChatChannelKind.Team: + await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); + break; + case ChatChannelKind.Clan: + await live.Connection.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false); + break; + default: + return ChatSendResult.Failed; + } + + return ChatSendResult.Sent; } catch (OperationCanceledException) { @@ -388,7 +401,7 @@ public async Task SendAsync( #pragma warning restore CA1031 { LogSendFailed(logger, ex, serverId); - return TeamChatSendResult.Failed; + return ChatSendResult.Failed; } } diff --git a/src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs index 6d720fe2..dbd5bab8 100644 --- a/src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs +++ b/src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Time; namespace RustPlusBot.Features.Workspace.Locating; @@ -10,12 +11,12 @@ namespace RustPlusBot.Features.Workspace.Locating; /// Opens scopes for the scoped workspace store. /// Drives the cache TTL. internal sealed class ClanChatChannelLocator(IServiceScopeFactory scopeFactory, IClock clock) - : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerClanChat) + : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerClanChat), IChatChannelLocator { - /// Resolves the (guild, server) pair a #clanchat channel belongs to, or null. - /// The Discord channel snowflake. - /// A cancellation token. - /// The owning guild and server, or null when the channel is not a provisioned #clanchat. + /// + public ChatChannelKind Kind => ChatChannelKind.Clan; + + /// public async Task<(ulong GuildId, Guid ServerId)?> ResolveAsync(ulong channelId, CancellationToken cancellationToken) { diff --git a/src/RustPlusBot.Features.Workspace/Locating/IChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/IChatChannelLocator.cs new file mode 100644 index 00000000..3e346fba --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/IChatChannelLocator.cs @@ -0,0 +1,32 @@ +using RustPlusBot.Abstractions.Chat; + +namespace RustPlusBot.Features.Workspace.Locating; + +/// +/// Resolves the per-server chat channel for one in both directions +/// (for the chat bridge). One implementation is registered per kind, so the bridge can select the +/// locator matching the channel it is relaying. +/// +public interface IChatChannelLocator +{ + /// Gets the in-game chat channel this locator maps to. + ChatChannelKind Kind { get; } + + /// + /// Gets the Discord channel id of this kind's chat channel for (, + /// ), or null. + /// + /// The guild snowflake. + /// The server id. + /// A cancellation token. + /// The Discord channel id, or null if not provisioned. + Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); + + /// + /// Resolves a Discord channel id to its (guild, server) if it is a chat channel of this kind, else null. + /// + /// The Discord channel snowflake. + /// A cancellation token. + /// The owning (guild, server), or null if the channel is not of this kind. + Task<(ulong GuildId, Guid ServerId)?> ResolveAsync(ulong channelId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Workspace/Locating/ITeamChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/ITeamChatChannelLocator.cs deleted file mode 100644 index f8841004..00000000 --- a/src/RustPlusBot.Features.Workspace/Locating/ITeamChatChannelLocator.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace RustPlusBot.Features.Workspace.Locating; - -/// Resolves the per-server #teamchat channel in both directions (for the chat bridge). -public interface ITeamChatChannelLocator -{ - /// Gets the Discord channel id of the #teamchat for (, ), or null. - /// The guild snowflake. - /// The server id. - /// A cancellation token. - /// The Discord channel id, or null if not provisioned. - Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); - - /// Resolves a Discord channel id to its (guild, server) if it is a #teamchat channel, else null. - /// The Discord channel snowflake. - /// A cancellation token. - /// The owning (guild, server), or null if the channel is not a #teamchat. - Task<(ulong GuildId, Guid ServerId)?> ResolveAsync(ulong channelId, CancellationToken cancellationToken); -} diff --git a/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs index 8f873eae..94bc5b98 100644 --- a/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs +++ b/src/RustPlusBot.Features.Workspace/Locating/TeamChatChannelLocator.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Time; namespace RustPlusBot.Features.Workspace.Locating; @@ -10,8 +11,11 @@ namespace RustPlusBot.Features.Workspace.Locating; /// Opens scopes for the scoped workspace store. /// Drives the cache TTL. internal sealed class TeamChatChannelLocator(IServiceScopeFactory scopeFactory, IClock clock) - : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerTeamChat), ITeamChatChannelLocator + : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerTeamChat), IChatChannelLocator { + /// + public ChatChannelKind Kind => ChatChannelKind.Team; + /// public async Task<(ulong GuildId, Guid ServerId)?> ResolveAsync(ulong channelId, CancellationToken cancellationToken) diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index 06a9d2d4..e6c60f4e 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -65,7 +65,8 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) services.AddSingleton(new InteractionModuleAssembly(typeof(WorkspaceServiceCollectionExtensions).Assembly)); // Channel locators (singleton with TTL cache; IClock + IServiceScopeFactory provided by the host). - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs index be95e875..f91897e9 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs @@ -42,7 +42,7 @@ private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL var teamChatSender = Substitute.For(); teamChatSender .SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TeamChatSendResult.Sent); + .Returns(ChatSendResult.Sent); var alarmLocalizer = new ResxLocalizer(); var clock = Substitute.For(); diff --git a/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs index 01ec0c0a..ecff6952 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs @@ -45,7 +45,7 @@ private static Harness Create() var relayPoster = Substitute.For(); var teamChatSender = Substitute.For(); teamChatSender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TeamChatSendResult.Sent); + .Returns(ChatSendResult.Sent); var alarmRenderer = new AlarmEmbedRenderer(new ResxLocalizer()); var relay = new AlarmStateRelay( diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs index 596b0493..dfb1a946 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using NSubstitute; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Chat.Hosting; @@ -36,8 +37,9 @@ public async Task Relay_and_processor_share_the_dedup_buffer() await using var provider = BuildProvider(out var locator, out var sender, out var poster); locator.ResolveAsync(channelId, Arg.Any()).Returns(((ulong, Guid)?)(guild, server)); locator.GetChannelIdAsync(guild, server, Arg.Any()).Returns((ulong?)channelId); - sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TeamChatSendResult.Sent); + sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(ChatSendResult.Sent); var processor = provider.GetRequiredService(); var relay = provider.GetRequiredService(); @@ -56,12 +58,12 @@ await poster.DidNotReceive() } private static ServiceProvider BuildProvider( - out ITeamChatChannelLocator locator, - out ITeamChatSender sender, + out IChatChannelLocator locator, + out IChatSender sender, out ITeamChatWebhookPoster poster) { - locator = Substitute.For(); - sender = Substitute.For(); + locator = Substitute.For(); + sender = Substitute.For(); poster = Substitute.For(); var services = new ServiceCollection(); diff --git a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs index 626c08af..4fa7fb6d 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Chat.Hosting; @@ -18,7 +19,7 @@ namespace RustPlusBot.Features.Chat.Tests.Hosting; public sealed class ChatHostedServiceTests { private static (ChatHostedService Service, InMemoryEventBus Bus, ITeamChatWebhookPoster Poster, - ITeamChatChannelLocator Locator) + IChatChannelLocator Locator) Build() { var clock = Substitute.For(); @@ -26,7 +27,7 @@ private static (ChatHostedService Service, InMemoryEventBus Bus, ITeamChatWebhoo var dedup = new RelayDedupBuffer(clock); var poster = Substitute.For(); - var locator = Substitute.For(); + var locator = Substitute.For(); locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((ulong?)555UL); // The relay reads the scoped IMuteStore command prefix per message; stub a scope that provides it. @@ -38,10 +39,10 @@ private static (ChatHostedService Service, InMemoryEventBus Bus, ITeamChatWebhoo relayScopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); relayScope.ServiceProvider.Returns(relayScopeProvider); relayScopeFactory.CreateScope().Returns(relayScope); - var relay = new TeamChatRelay(locator, poster, dedup, relayScopeFactory); + var relay = new TeamChatRelay([locator], poster, dedup, relayScopeFactory); - var inboundLocator = Substitute.For(); - var sender = Substitute.For(); + var inboundLocator = Substitute.For(); + var sender = Substitute.For(); // Processor not exercised by bus-side tests; stub scope factory is sufficient. var processor = ChatHostedServiceTestAccess.BuildProcessor(inboundLocator, sender, dedup); @@ -121,12 +122,12 @@ await bus.PublishAsync( internal static class ChatHostedServiceTestAccess { internal static TeamChatInboundProcessor BuildProcessor( - ITeamChatChannelLocator locator, - ITeamChatSender sender, + IChatChannelLocator locator, + IChatSender sender, RelayDedupBuffer dedup) { // A NullScopeFactory is sufficient — processor is not exercised by the bus relay path under test. var scopeFactory = Substitute.For(); - return new TeamChatInboundProcessor(locator, sender, dedup, scopeFactory); + return new TeamChatInboundProcessor([locator], sender, dedup, scopeFactory); } } diff --git a/tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs b/tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs index 68a9ddb8..28d06f43 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/RelayDedupBufferTests.cs @@ -1,6 +1,6 @@ using NSubstitute; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Time; -using RustPlusBot.Features.Chat.Relaying; namespace RustPlusBot.Features.Chat.Tests; @@ -18,10 +18,10 @@ public void Consumes_a_matching_entry_once() { var (buffer, _) = Build(); var key = (10UL, Guid.Empty); - buffer.Record(key, "[Alice] hi"); + buffer.Record(ChatChannelKind.Team, key, "[Alice] hi"); - Assert.True(buffer.TryConsume(key, "[Alice] hi")); - Assert.False(buffer.TryConsume(key, "[Alice] hi")); // already consumed + Assert.True(buffer.TryConsume(ChatChannelKind.Team, key, "[Alice] hi")); + Assert.False(buffer.TryConsume(ChatChannelKind.Team, key, "[Alice] hi")); // already consumed } [Fact] @@ -29,9 +29,9 @@ public void Does_not_match_other_text() { var (buffer, _) = Build(); var key = (10UL, Guid.Empty); - buffer.Record(key, "[Alice] hi"); + buffer.Record(ChatChannelKind.Team, key, "[Alice] hi"); - Assert.False(buffer.TryConsume(key, "[Bob] hi")); + Assert.False(buffer.TryConsume(ChatChannelKind.Team, key, "[Bob] hi")); } [Fact] @@ -39,11 +39,11 @@ public void Entry_expires_after_ttl() { var (buffer, clock) = Build(); var key = (10UL, Guid.Empty); - buffer.Record(key, "[Alice] hi"); + buffer.Record(ChatChannelKind.Team, key, "[Alice] hi"); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch + TimeSpan.FromMinutes(1)); - Assert.False(buffer.TryConsume(key, "[Alice] hi")); + Assert.False(buffer.TryConsume(ChatChannelKind.Team, key, "[Alice] hi")); } [Fact] @@ -51,11 +51,34 @@ public void Two_identical_records_consume_independently() { var (buffer, _) = Build(); var key = (10UL, Guid.Empty); - buffer.Record(key, "[Alice] hi"); - buffer.Record(key, "[Alice] hi"); + buffer.Record(ChatChannelKind.Team, key, "[Alice] hi"); + buffer.Record(ChatChannelKind.Team, key, "[Alice] hi"); - Assert.True(buffer.TryConsume(key, "[Alice] hi")); - Assert.True(buffer.TryConsume(key, "[Alice] hi")); - Assert.False(buffer.TryConsume(key, "[Alice] hi")); + Assert.True(buffer.TryConsume(ChatChannelKind.Team, key, "[Alice] hi")); + Assert.True(buffer.TryConsume(ChatChannelKind.Team, key, "[Alice] hi")); + Assert.False(buffer.TryConsume(ChatChannelKind.Team, key, "[Alice] hi")); + } + + [Fact] + public void A_clan_echo_does_not_consume_an_identical_team_entry() + { + var (buffer, _) = Build(); + var key = (1UL, Guid.NewGuid()); + + buffer.Record(ChatChannelKind.Team, key, "[dave] hello"); + + Assert.False(buffer.TryConsume(ChatChannelKind.Clan, key, "[dave] hello")); + Assert.True(buffer.TryConsume(ChatChannelKind.Team, key, "[dave] hello")); + } + + [Fact] + public void Consumes_a_clan_entry_recorded_for_the_same_key() + { + var (buffer, _) = Build(); + var key = (1UL, Guid.NewGuid()); + + buffer.Record(ChatChannelKind.Clan, key, "[dave] hello"); + + Assert.True(buffer.TryConsume(ChatChannelKind.Clan, key, "[dave] hello")); } } diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs index 13df732b..5bea4662 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Chat.Inbound; using RustPlusBot.Features.Chat.Relaying; @@ -13,16 +14,17 @@ public sealed class TeamChatInboundProcessorTests { private static readonly Guid ServerId = Guid.NewGuid(); - private static (TeamChatInboundProcessor Processor, ITeamChatSender Sender, RelayDedupBuffer Dedup, IMuteStore Mute) - Build(TeamChatSendResult sendResult = TeamChatSendResult.Sent) + private static (TeamChatInboundProcessor Processor, IChatSender Sender, RelayDedupBuffer Dedup, IMuteStore Mute) + Build(ChatSendResult sendResult = ChatSendResult.Sent) { var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); var dedup = new RelayDedupBuffer(clock); - var sender = Substitute.For(); - sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + var sender = Substitute.For(); + sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) .Returns(sendResult); - var locator = Substitute.For(); + var locator = Substitute.For(); locator.ResolveAsync(777UL, Arg.Any()).Returns(((ulong, Guid)?)(10UL, ServerId)); locator.ResolveAsync(Arg.Is(c => c != 777UL), Arg.Any()) .Returns(((ulong, Guid)?)null); @@ -34,7 +36,7 @@ private static (TeamChatInboundProcessor Processor, ITeamChatSender Sender, Rela scopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); scope.ServiceProvider.Returns(scopeProvider); scopeFactory.CreateScope().Returns(scope); - var processor = new TeamChatInboundProcessor(locator, sender, dedup, scopeFactory); + var processor = new TeamChatInboundProcessor([locator], sender, dedup, scopeFactory); return (processor, sender, dedup, muteStore); } @@ -47,8 +49,8 @@ public async Task Ignores_bot_or_webhook_authors() var outcome = await processor.ProcessAsync(msg, CancellationToken.None); Assert.Equal(InboundOutcome.Ignored, outcome); - await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); } [Fact] @@ -60,8 +62,8 @@ public async Task Ignores_non_teamchat_channels() var outcome = await processor.ProcessAsync(msg, CancellationToken.None); Assert.Equal(InboundOutcome.Ignored, outcome); - await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); } [Fact] @@ -73,8 +75,8 @@ public async Task Ignores_empty_content() var outcome = await processor.ProcessAsync(msg, CancellationToken.None); Assert.Equal(InboundOutcome.Ignored, outcome); - await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); } [Fact] @@ -86,14 +88,15 @@ public async Task Formats_records_and_sends() var outcome = await processor.ProcessAsync(msg, CancellationToken.None); Assert.Equal(InboundOutcome.Sent, outcome); - await sender.Received(1).SendAsync(10UL, ServerId, "[Alice] hello", Arg.Any()); - Assert.True(dedup.TryConsume((10UL, ServerId), "[Alice] hello")); + await sender.Received(1) + .SendAsync(ChatChannelKind.Team, 10UL, ServerId, "[Alice] hello", Arg.Any()); + Assert.True(dedup.TryConsume(ChatChannelKind.Team, (10UL, ServerId), "[Alice] hello")); } [Fact] public async Task Reports_failed_when_not_connected() { - var (processor, _, _, _) = Build(TeamChatSendResult.NotConnected); + var (processor, _, _, _) = Build(ChatSendResult.NotConnected); var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", "hello"); var outcome = await processor.ProcessAsync(msg, CancellationToken.None); @@ -111,7 +114,7 @@ public async Task Ignores_muted_server() var outcome = await processor.ProcessAsync(msg, CancellationToken.None); Assert.Equal(InboundOutcome.Ignored, outcome); - await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); } } diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs index 3fc9d44d..1756f36a 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Chat.Relaying; @@ -11,7 +12,7 @@ namespace RustPlusBot.Features.Chat.Tests; public sealed class TeamChatRelayTests { - private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBuffer Dedup, ITeamChatChannelLocator + private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBuffer Dedup, IChatChannelLocator Locator) Build(string prefix = "!") { @@ -19,7 +20,7 @@ private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBu clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); var dedup = new RelayDedupBuffer(clock); var poster = Substitute.For(); - var locator = Substitute.For(); + var locator = Substitute.For(); locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((ulong?)777UL); var muteStore = Substitute.For(); @@ -30,7 +31,7 @@ private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBu scopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); scope.ServiceProvider.Returns(scopeProvider); scopeFactory.CreateScope().Returns(scope); - var relay = new TeamChatRelay(locator, poster, dedup, scopeFactory); + var relay = new TeamChatRelay([locator], poster, dedup, scopeFactory); return (relay, poster, dedup, locator); } @@ -50,7 +51,7 @@ public async Task Drops_our_own_echo() { var (relay, poster, dedup, _) = Build(); var key = (10UL, Guid.Empty); - dedup.Record(key, "[Alice] hello"); + dedup.Record(ChatChannelKind.Team, key, "[Alice] hello"); var echo = new TeamMessageReceivedEvent(10UL, Guid.Empty, 555UL, "BotPlayer", "[Alice] hello", FromActivePlayer: true); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Hosting/CommandsHostedServiceTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Hosting/CommandsHostedServiceTests.cs index 7987f592..e97c975f 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Hosting/CommandsHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Hosting/CommandsHostedServiceTests.cs @@ -18,7 +18,7 @@ private static Harness Create(bool prefixThrows = false) { var sender = Substitute.For(); sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TeamChatSendResult.Sent); + .Returns(ChatSendResult.Sent); var muteStore = Substitute.For(); muteStore.GetMutedAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); diff --git a/tests/RustPlusBot.Features.Connections.Tests/BotTeamChatSenderTests.cs b/tests/RustPlusBot.Features.Connections.Tests/BotTeamChatSenderTests.cs index aa3d4fc6..86420cf2 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/BotTeamChatSenderTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/BotTeamChatSenderTests.cs @@ -1,4 +1,5 @@ using NSubstitute; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Features.Connections.Listening; namespace RustPlusBot.Features.Connections.Tests; @@ -8,26 +9,29 @@ public sealed class BotTeamChatSenderTests [Fact] public async Task Prefixes_the_message_and_forwards() { - var inner = Substitute.For(); - inner.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TeamChatSendResult.Sent); + var inner = Substitute.For(); + inner.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(ChatSendResult.Sent); var serverId = Guid.NewGuid(); var sut = new BotTeamChatSender(inner); var result = await sut.SendAsync(10UL, serverId, "hello", CancellationToken.None); - Assert.Equal(TeamChatSendResult.Sent, result); - await inner.Received(1).SendAsync(10UL, serverId, "[R+] hello", Arg.Any()); + Assert.Equal(ChatSendResult.Sent, result); + await inner.Received(1) + .SendAsync(ChatChannelKind.Team, 10UL, serverId, "[R+] hello", Arg.Any()); } [Theory] - [InlineData(TeamChatSendResult.Sent)] - [InlineData(TeamChatSendResult.NotConnected)] - [InlineData(TeamChatSendResult.Failed)] - public async Task Passes_the_result_through(TeamChatSendResult expected) + [InlineData(ChatSendResult.Sent)] + [InlineData(ChatSendResult.NotConnected)] + [InlineData(ChatSendResult.Failed)] + public async Task Passes_the_result_through(ChatSendResult expected) { - var inner = Substitute.For(); - inner.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + var inner = Substitute.For(); + inner.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) .Returns(expected); var sut = new BotTeamChatSender(inner); diff --git a/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs index f91a9b03..2b297348 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; @@ -429,6 +430,39 @@ public async Task Stops_publishing_clan_events_after_the_socket_closes() } } + [Fact] + public async Task Routes_a_team_send_to_team_chat_and_a_clan_send_to_clan_chat() + { + // One IChatSender, two destinations: assert the kind selects the right socket call and + // that neither leaks into the other's buffer. + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => h.Supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + var teamResult = + await h.Supervisor.SendAsync(ChatChannelKind.Team, 10UL, serverId, "[Alice] team line", cts.Token); + var clanResult = + await h.Supervisor.SendAsync(ChatChannelKind.Clan, 10UL, serverId, "[Bob] clan line", cts.Token); + + Assert.Equal(ChatSendResult.Sent, teamResult); + Assert.Equal(ChatSendResult.Sent, clanResult); + + Assert.NotNull(source.LastConnection); + var connection = source.LastConnection!; + + // Assert both destinations independently, so swapping the routing fails on both sides. + Assert.Equal("[Alice] team line", Assert.Single(connection.SentMessages)); + Assert.Equal("[Bob] clan line", Assert.Single(connection.SentClanMessages)); + + await h.Supervisor.StopAllAsync(); + } + private sealed class Harness : IAsyncDisposable { public required ServiceProvider Provider { get; init; } diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs index ae825254..c3c0f708 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; @@ -149,9 +150,9 @@ public async Task SendAsync_routes_to_live_socket_when_connected() await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); - var result = await supervisor.SendAsync(10UL, serverId, "[Alice] hi", cts.Token); + var result = await supervisor.SendAsync(ChatChannelKind.Team, 10UL, serverId, "[Alice] hi", cts.Token); - Assert.Equal(TeamChatSendResult.Sent, result); + Assert.Equal(ChatSendResult.Sent, result); Assert.Contains("[Alice] hi", source.LastConnection!.SentMessages); await supervisor.StopAllAsync(); } @@ -163,9 +164,10 @@ public async Task SendAsync_returns_NotConnected_when_no_socket() var (provider, supervisor, _) = CreateHarness(source); await using var _p = provider; - var result = await supervisor.SendAsync(10UL, Guid.NewGuid(), "hi", CancellationToken.None); + var result = await supervisor.SendAsync(ChatChannelKind.Team, 10UL, Guid.NewGuid(), "hi", + CancellationToken.None); - Assert.Equal(TeamChatSendResult.NotConnected, result); + Assert.Equal(ChatSendResult.NotConnected, result); } private static async Task WaitUntilAsync(Func condition, CancellationToken ct) diff --git a/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs b/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs index ccab54c7..578da7d2 100644 --- a/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs @@ -37,7 +37,7 @@ private static Harness Create() var poster = Substitute.For(); var sender = Substitute.For(); sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TeamChatSendResult.Sent); + .Returns(ChatSendResult.Sent); var relay = new PlayerEventRelay( new PlayerEventRenderer(new ResxLocalizer()), diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs index 95e684cb..695d5b3a 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs @@ -42,7 +42,7 @@ public void AddWorkspace_registers_channel_locators() var services = new ServiceCollection(); services.AddWorkspace(); - Assert.Contains(services, d => d.ServiceType == typeof(ITeamChatChannelLocator)); + Assert.Equal(2, services.Count(d => d.ServiceType == typeof(IChatChannelLocator))); Assert.Contains(services, d => d.ServiceType == typeof(IEventChannelLocator)); Assert.Contains(services, d => d.ServiceType == typeof(IMapChannelLocator)); Assert.Contains(services, d => d.ServiceType == typeof(ISwitchChannelLocator)); From 5367fc33ce978cc60b3b70cea6bc70dd22fdd38b Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 00:23:47 +0200 Subject: [PATCH 16/39] Close chat locator Kind coverage gap in WorkspaceRegistrationTests Registration count alone can't catch a swapped Kind between TeamChatChannelLocator and ClanChatChannelLocator, which would silently misroute team/clan chat in production. Resolve the locators and assert each Kind pairs with its expected concrete type; verified the assertion fails when the two Kind values are swapped and passes when restored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../WorkspaceRegistrationTests.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs index 695d5b3a..3d83fd8b 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs @@ -1,6 +1,7 @@ using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; @@ -50,4 +51,33 @@ public void AddWorkspace_registers_channel_locators() Assert.Contains(services, d => d.ServiceType == typeof(IStorageMonitorChannelLocator)); Assert.Contains(services, d => d.ServiceType == typeof(ISetupChannelLocator)); } + + [Fact] + public void AddWorkspace_registers_one_chat_channel_locator_per_kind_with_matching_concrete_type() + { + var services = new ServiceCollection(); + services.AddSingleton(new DiscordSocketClient()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(Substitute.For()); + services.AddLogging(); + services.AddBotPersistence("DataSource=:memory:"); + services.AddWorkspace(); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + using var scope = provider.CreateScope(); + + var locators = scope.ServiceProvider.GetServices().ToList(); + + Assert.Equal(2, locators.Count); + + var teamLocator = Assert.Single(locators, l => l.Kind == ChatChannelKind.Team); + Assert.IsType(teamLocator); + + var clanLocator = Assert.Single(locators, l => l.Kind == ChatChannelKind.Clan); + Assert.IsType(clanLocator); + } } From 918ce978c260b7e8c5257683cd82dcc27544f859 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 00:37:58 +0200 Subject: [PATCH 17/39] Drive the chat bridge by channel kind to serve clan chat Generalise the relay, webhook poster and inbound processor over ChatChannelKind so one bridge serves both team and clan chat, and subscribe the hosted service to both message events. The inbound path picks its kind from whichever locator claims the channel. Clan chat senders are recorded as the name source for the clan roster. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ChatServiceCollectionExtensions.cs | 8 +- .../Hosting/ChatHostedService.cs | 95 +++++-- ...ndProcessor.cs => ChatInboundProcessor.cs} | 32 ++- .../Inbound/InboundOutcome.cs | 4 +- .../{TeamChatRelay.cs => ChatRelay.cs} | 44 +-- .../Relaying/RelayedChatLine.cs | 18 ++ .../Webhooks/DiscordChatWebhookPoster.cs | 109 ++++++++ .../Webhooks/DiscordTeamChatWebhookPoster.cs | 84 ------ ...WebhookPoster.cs => IChatWebhookPoster.cs} | 14 +- .../ChatInboundProcessorTests.cs | 195 +++++++++++++ .../ChatRegistrationTests.cs | 73 +++-- .../ChatRelayTests.cs | 260 ++++++++++++++++++ .../Hosting/ChatHostedServiceTests.cs | 132 +++++++-- .../TeamChatInboundProcessorTests.cs | 120 -------- .../TeamChatRelayTests.cs | 140 ---------- 15 files changed, 883 insertions(+), 445 deletions(-) rename src/RustPlusBot.Features.Chat/Inbound/{TeamChatInboundProcessor.cs => ChatInboundProcessor.cs} (67%) rename src/RustPlusBot.Features.Chat/Relaying/{TeamChatRelay.cs => ChatRelay.cs} (55%) create mode 100644 src/RustPlusBot.Features.Chat/Relaying/RelayedChatLine.cs create mode 100644 src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs delete mode 100644 src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs rename src/RustPlusBot.Features.Chat/Webhooks/{ITeamChatWebhookPoster.cs => IChatWebhookPoster.cs} (57%) create mode 100644 tests/RustPlusBot.Features.Chat.Tests/ChatInboundProcessorTests.cs create mode 100644 tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs delete mode 100644 tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs delete mode 100644 tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs diff --git a/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs index 5c21337d..e151e95d 100644 --- a/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ namespace RustPlusBot.Features.Chat; -/// DI registration for the team chat bridge. +/// DI registration for the chat bridge (team and clan chat). public static class ChatServiceCollectionExtensions { /// Registers the dedup buffer, webhook poster, relay, inbound processor, and hosted service. @@ -18,9 +18,9 @@ public static IServiceCollection AddChat(this IServiceCollection services) ArgumentNullException.ThrowIfNull(services); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); return services; diff --git a/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs b/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs index 629c4918..98fd228f 100644 --- a/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs +++ b/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs @@ -1,28 +1,34 @@ using Discord; using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Chat.Inbound; using RustPlusBot.Features.Chat.Relaying; +using RustPlusBot.Persistence.Clans; namespace RustPlusBot.Features.Chat.Hosting; -/// Runs the game→Discord relay loop and the Discord→game #teamchat listener. +/// Runs the game→Discord relay loops (team and clan) and the Discord→game chat channel listener. /// The Discord socket client (for MessageReceived). /// The in-process event bus. -/// Relays received team messages into Discord. -/// Processes Discord #teamchat messages for relay into the game. +/// Relays received in-game chat lines into Discord. +/// Processes Discord chat channel messages for relay into the game. +/// Opens a scope to record clan sender names via the scoped . /// The logger. internal sealed partial class ChatHostedService( DiscordSocketClient client, IEventBus eventBus, - TeamChatRelay relay, - TeamChatInboundProcessor processor, + ChatRelay relay, + ChatInboundProcessor processor, + IServiceScopeFactory scopeFactory, ILogger logger) : IHostedService, IDisposable { private readonly CancellationTokenSource _cts = new(); - private Task? _relayLoop; + private Task? _teamLoop; + private Task? _clanLoop; /// public void Dispose() => _cts.Dispose(); @@ -31,7 +37,8 @@ internal sealed partial class ChatHostedService( public Task StartAsync(CancellationToken cancellationToken) { client.MessageReceived += OnMessageReceivedAsync; - _relayLoop = Task.Run(() => ConsumeTeamMessagesAsync(_cts.Token), CancellationToken.None); + _teamLoop = Task.Run(() => ConsumeTeamMessagesAsync(_cts.Token), CancellationToken.None); + _clanLoop = Task.Run(() => ConsumeClanMessagesAsync(_cts.Token), CancellationToken.None); return Task.CompletedTask; } @@ -40,18 +47,28 @@ public async Task StopAsync(CancellationToken cancellationToken) { client.MessageReceived -= OnMessageReceivedAsync; await _cts.CancelAsync().ConfigureAwait(false); - if (_relayLoop is not null) +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — these are our own loop tasks, joined on stop. + await JoinLoopAsync(_teamLoop).ConfigureAwait(false); + await JoinLoopAsync(_clanLoop).ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + + private static async Task JoinLoopAsync(Task? loop) + { + if (loop is null) + { + return; + } + + try { - try - { #pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — this is our own loop task, joined on stop. - await _relayLoop.ConfigureAwait(false); + await loop.ConfigureAwait(false); #pragma warning restore VSTHRD003 - } - catch (OperationCanceledException) - { - // Expected on shutdown. - } + } + catch (OperationCanceledException) + { + // Expected on shutdown. } } @@ -62,7 +79,9 @@ private async Task ConsumeTeamMessagesAsync(CancellationToken cancellationToken) await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) .ConfigureAwait(false)) { - await relay.RelayAsync(evt, cancellationToken).ConfigureAwait(false); + await relay.RelayAsync( + new RelayedChatLine(ChatChannelKind.Team, evt.GuildId, evt.ServerId, evt.SenderName, + evt.Message, evt.FromActivePlayer), cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -73,7 +92,40 @@ private async Task ConsumeTeamMessagesAsync(CancellationToken cancellationToken) catch (Exception ex) #pragma warning restore CA1031 { - LogRelayLoopFaulted(logger, ex); + LogTeamRelayLoopFaulted(logger, ex); + } + } + + private async Task ConsumeClanMessagesAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + // Clan members arrive as Steam ids only; chat is where we learn their names. + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + await store.RecordNameAsync(evt.GuildId, evt.ServerId, evt.SenderSteamId, evt.SenderName, + cancellationToken).ConfigureAwait(false); + } + + await relay.RelayAsync( + new RelayedChatLine(ChatChannelKind.Clan, evt.GuildId, evt.ServerId, evt.SenderName, + evt.Message, evt.FromActivePlayer), 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 + { + LogClanRelayLoopFaulted(logger, ex); } } @@ -107,8 +159,11 @@ private async Task OnMessageReceivedAsync(SocketMessage message) } [LoggerMessage(Level = LogLevel.Error, Message = "Team message relay loop faulted.")] - private static partial void LogRelayLoopFaulted(ILogger logger, Exception exception); + private static partial void LogTeamRelayLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Clan message relay loop faulted.")] + private static partial void LogClanRelayLoopFaulted(ILogger logger, Exception exception); - [LoggerMessage(Level = LogLevel.Error, Message = "Team chat inbound listener faulted.")] + [LoggerMessage(Level = LogLevel.Error, Message = "Chat inbound listener faulted.")] private static partial void LogListenerFaulted(ILogger logger, Exception exception); } diff --git a/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs b/src/RustPlusBot.Features.Chat/Inbound/ChatInboundProcessor.cs similarity index 67% rename from src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs rename to src/RustPlusBot.Features.Chat/Inbound/ChatInboundProcessor.cs index 77fba84a..2fc937e2 100644 --- a/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs +++ b/src/RustPlusBot.Features.Chat/Inbound/ChatInboundProcessor.cs @@ -7,18 +7,21 @@ namespace RustPlusBot.Features.Chat.Inbound; -/// Turns a Discord #teamchat message into an in-game relay (record-then-send), reporting the outcome. -/// The registered chat channel locators; the team one is selected from these. +/// +/// Turns a Discord chat-channel message into an in-game relay (record-then-send), reporting the outcome. +/// The channel kind comes from whichever registered locator claims the channel. +/// +/// The registered chat channel locators; the one claiming the channel supplies the kind. /// Relays the formatted line into the game. /// Records the relayed line so its in-game echo can be dropped. /// Opens a scope to read the scoped mute gate. -internal sealed class TeamChatInboundProcessor( +internal sealed class ChatInboundProcessor( IEnumerable locators, IChatSender sender, RelayDedupBuffer dedup, IServiceScopeFactory scopeFactory) { - private readonly IChatChannelLocator _locator = locators.Single(l => l.Kind == ChatChannelKind.Team); + private readonly IReadOnlyList _locators = [.. locators]; /// Processes one observed Discord message. /// The reduced message. @@ -26,12 +29,27 @@ internal sealed class TeamChatInboundProcessor( /// What was done with the message. public async Task ProcessAsync(InboundMessage message, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(message); + if (message.AuthorIsBotOrWebhook || string.IsNullOrWhiteSpace(message.Content)) { return InboundOutcome.Ignored; } - var target = await _locator.ResolveAsync(message.ChannelId, cancellationToken).ConfigureAwait(false); + // Whichever locator claims this channel supplies both its owning guild and server and its channel + // kind. A channel that no locator claims is not a chat channel, so it is ignored. + (ulong GuildId, Guid ServerId)? target = null; + var kind = ChatChannelKind.Team; + foreach (var locator in _locators) + { + if (await locator.ResolveAsync(message.ChannelId, cancellationToken).ConfigureAwait(false) is { } hit) + { + target = hit; + kind = locator.Kind; + break; + } + } + if (target is not { } t) { return InboundOutcome.Ignored; @@ -53,9 +71,9 @@ public async Task ProcessAsync(InboundMessage message, Cancellat var text = string.Create(CultureInfo.InvariantCulture, $"[{message.DisplayName}] {message.Content}"); // Record BEFORE sending: the in-game echo can arrive before SendAsync returns. Unused entries expire. - dedup.Record(ChatChannelKind.Team, key, text); + dedup.Record(kind, key, text); var result = await sender - .SendAsync(ChatChannelKind.Team, t.GuildId, t.ServerId, text, cancellationToken) + .SendAsync(kind, t.GuildId, t.ServerId, text, cancellationToken) .ConfigureAwait(false); return result == ChatSendResult.Sent ? InboundOutcome.Sent : InboundOutcome.Failed; diff --git a/src/RustPlusBot.Features.Chat/Inbound/InboundOutcome.cs b/src/RustPlusBot.Features.Chat/Inbound/InboundOutcome.cs index 30264e97..aba41faf 100644 --- a/src/RustPlusBot.Features.Chat/Inbound/InboundOutcome.cs +++ b/src/RustPlusBot.Features.Chat/Inbound/InboundOutcome.cs @@ -3,12 +3,12 @@ namespace RustPlusBot.Features.Chat.Inbound; /// What the inbound processor did with a Discord message. internal enum InboundOutcome { - /// Not a #teamchat message, empty, or a bot/webhook author — nothing relayed. + /// Not a chat channel message, empty, or a bot/webhook author — nothing relayed. Ignored = 0, /// Relayed into the game. Sent = 1, - /// Belongs to a #teamchat but could not be relayed (no live socket or send failed). + /// Belongs to a chat channel but could not be relayed (no live socket or send failed). Failed = 2, } diff --git a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs similarity index 55% rename from src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs rename to src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs index 45c1d413..646ece8b 100644 --- a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs +++ b/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Abstractions.Chat; -using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Chat.Webhooks; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Locating; @@ -9,41 +8,48 @@ namespace RustPlusBot.Features.Chat.Relaying; /// -/// Relays one received in-game team message into its Discord #teamchat channel, dropping our own -/// echoes and command invocations. +/// Relays one received in-game chat line into the Discord channel for its , +/// dropping our own echoes and command invocations. /// -/// The registered chat channel locators; the team one is selected from these. +/// The registered chat channel locators, indexed by kind. /// Posts the line via webhook. /// Tracks lines the bridge relayed into the game so their echoes can be dropped. /// Opens a scope to read the scoped command prefix. -internal sealed class TeamChatRelay( +internal sealed class ChatRelay( IEnumerable locators, - ITeamChatWebhookPoster poster, + IChatWebhookPoster poster, RelayDedupBuffer dedup, IServiceScopeFactory scopeFactory) { - private readonly IChatChannelLocator _locator = locators.Single(l => l.Kind == ChatChannelKind.Team); + private readonly Dictionary _locators = locators.ToDictionary(l => l.Kind); - /// Handles one . - /// The received team message. + /// Relays one received in-game chat line into its Discord channel. + /// The received line. /// A cancellation token. /// A task that completes when the line has been relayed or dropped. - public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken cancellationToken) + public async Task RelayAsync(RelayedChatLine line, CancellationToken cancellationToken) { - if (evt.FromActivePlayer && evt.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) + ArgumentNullException.ThrowIfNull(line); + + if (line.FromActivePlayer && line.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) { - return; // Bot-originated line echoing back; #teamchat carries only human discussion. + return; // Bot-originated line echoing back; the channel carries only human discussion. } - if (evt.FromActivePlayer && - dedup.TryConsume(ChatChannelKind.Team, (evt.GuildId, evt.ServerId), evt.Message)) + if (line.FromActivePlayer && + dedup.TryConsume(line.Kind, (line.GuildId, line.ServerId), line.Message)) { return; // Our own relayed line echoing back; do not re-post. } + if (!_locators.TryGetValue(line.Kind, out var locator)) + { + return; + } + // The locator is an in-memory cache, so resolve the channel first: unmapped servers exit // before the per-message prefix query below. - var channelId = await _locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + var channelId = await locator.GetChannelIdAsync(line.GuildId, line.ServerId, cancellationToken) .ConfigureAwait(false); if (channelId is null) { @@ -51,14 +57,16 @@ public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken can } // A command invocation (e.g. "!pop") gets its reply in game; the bare trigger line is noise in Discord. - var prefix = await GetCommandPrefixAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + var prefix = await GetCommandPrefixAsync(line.GuildId, line.ServerId, cancellationToken) + .ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(prefix) && - evt.Message.TrimStart().StartsWith(prefix, StringComparison.Ordinal)) + line.Message.TrimStart().StartsWith(prefix, StringComparison.Ordinal)) { return; } - await poster.PostAsync(channelId.Value, evt.SenderName, evt.Message, cancellationToken).ConfigureAwait(false); + await poster.PostAsync(line.Kind, channelId.Value, line.SenderName, line.Message, cancellationToken) + .ConfigureAwait(false); } private async Task GetCommandPrefixAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) diff --git a/src/RustPlusBot.Features.Chat/Relaying/RelayedChatLine.cs b/src/RustPlusBot.Features.Chat/Relaying/RelayedChatLine.cs new file mode 100644 index 00000000..59fe6b70 --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Relaying/RelayedChatLine.cs @@ -0,0 +1,18 @@ +using RustPlusBot.Abstractions.Chat; + +namespace RustPlusBot.Features.Chat.Relaying; + +/// One received in-game chat line, normalised across channel kinds. +/// The in-game chat channel the line was received on. +/// The owning guild snowflake. +/// The server whose socket received the line. +/// The in-game display name of the sender. +/// The message text. +/// True when the sender is the bot's active player (used to drop relay echoes). +internal sealed record RelayedChatLine( + ChatChannelKind Kind, + ulong GuildId, + Guid ServerId, + string SenderName, + string Message, + bool FromActivePlayer); diff --git a/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs new file mode 100644 index 00000000..065a9d1a --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs @@ -0,0 +1,109 @@ +using System.Collections.Concurrent; +using Discord; +using Discord.Webhook; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Chat; + +namespace RustPlusBot.Features.Chat.Webhooks; + +/// +/// Real . Ensures one webhook per (channel kind, channel), named after the +/// kind (created if missing, re-discovered by name on restart) and caches the webhook client per +/// (kind, channel). Untested integration shim. +/// +/// The Discord socket client. +/// The logger. +internal sealed partial class DiscordChatWebhookPoster( + DiscordSocketClient client, + ILogger logger) + : IChatWebhookPoster, IAsyncDisposable +{ + private readonly ConcurrentDictionary<(ChatChannelKind Kind, ulong ChannelId), DiscordWebhookClient> _clients = + new(); + + /// + public ValueTask DisposeAsync() + { + foreach (var c in _clients.Values) + { + c.Dispose(); + } + + return ValueTask.CompletedTask; + } + + /// + public async Task PostAsync( + ChatChannelKind kind, + ulong channelId, + string username, + string message, + CancellationToken cancellationToken) + { + try + { + var webhook = await GetOrCreateClientAsync(kind, channelId).ConfigureAwait(false); + if (webhook is null) + { + return; + } + + await webhook.SendMessageAsync(message, username: username, allowedMentions: AllowedMentions.None) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // Broad catch: a webhook post failure must not crash the relay loop. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPostFailed(logger, ex, kind, channelId); + } + } + + /// + /// Webhook name per channel kind. These strings are load-bearing: the poster re-discovers its + /// webhook by name on restart, so changing one orphans every webhook already created in live + /// guilds and silently creates a duplicate alongside it. + /// + /// The channel kind. + /// The webhook name to find or create. + private static string WebhookNameFor(ChatChannelKind kind) => kind switch + { + ChatChannelKind.Clan => "RustPlusBot ClanChat", + _ => "RustPlusBot TeamChat", + }; + + private async Task GetOrCreateClientAsync(ChatChannelKind kind, ulong channelId) + { + if (_clients.TryGetValue((kind, channelId), out var cached)) + { + return cached; + } + + if (await client.GetChannelAsync(channelId).ConfigureAwait(false) is not ITextChannel channel) + { + return null; + } + + var name = WebhookNameFor(kind); + var hooks = await channel.GetWebhooksAsync().ConfigureAwait(false); + var hook = hooks.FirstOrDefault(h => string.Equals(h.Name, name, StringComparison.Ordinal)) + ?? await channel.CreateWebhookAsync(name).ConfigureAwait(false); + var webhookClient = new DiscordWebhookClient(hook); + var stored = _clients.GetOrAdd((kind, channelId), webhookClient); + if (!ReferenceEquals(stored, webhookClient)) + { + // Lost the race to cache the client (another caller cached one first); dispose the redundant one. + webhookClient.Dispose(); + } + + return stored; + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Posting a {Kind} chat line to channel {ChannelId} failed.")] + private static partial void LogPostFailed( + ILogger logger, + Exception exception, + ChatChannelKind kind, + ulong channelId); +} diff --git a/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs deleted file mode 100644 index dc65b10f..00000000 --- a/src/RustPlusBot.Features.Chat/Webhooks/DiscordTeamChatWebhookPoster.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Collections.Concurrent; -using Discord; -using Discord.Webhook; -using Discord.WebSocket; -using Microsoft.Extensions.Logging; - -namespace RustPlusBot.Features.Chat.Webhooks; - -/// -/// Real . Ensures one webhook named per channel -/// (created if missing, re-discovered by name on restart) and caches the webhook client. Untested integration shim. -/// -/// The Discord socket client. -/// The logger. -internal sealed partial class DiscordTeamChatWebhookPoster( - DiscordSocketClient client, - ILogger logger) - : ITeamChatWebhookPoster, IAsyncDisposable -{ - private const string WebhookName = "RustPlusBot TeamChat"; - private readonly ConcurrentDictionary _clients = new(); - - /// - public ValueTask DisposeAsync() - { - foreach (var c in _clients.Values) - { - c.Dispose(); - } - - return ValueTask.CompletedTask; - } - - /// - public async Task PostAsync(ulong channelId, string username, string message, CancellationToken cancellationToken) - { - try - { - var webhook = await GetOrCreateClientAsync(channelId).ConfigureAwait(false); - if (webhook is null) - { - return; - } - - await webhook.SendMessageAsync(message, username: username, allowedMentions: AllowedMentions.None) - .ConfigureAwait(false); - } -#pragma warning disable CA1031 // Broad catch: a webhook post failure must not crash the relay loop. - catch (Exception ex) -#pragma warning restore CA1031 - { - LogPostFailed(logger, ex, channelId); - } - } - - private async Task GetOrCreateClientAsync(ulong channelId) - { - if (_clients.TryGetValue(channelId, out var cached)) - { - return cached; - } - - if (await client.GetChannelAsync(channelId).ConfigureAwait(false) is not ITextChannel channel) - { - return null; - } - - var hooks = await channel.GetWebhooksAsync().ConfigureAwait(false); - var hook = hooks.FirstOrDefault(h => h.Name == WebhookName) - ?? await channel.CreateWebhookAsync(WebhookName).ConfigureAwait(false); - var webhookClient = new DiscordWebhookClient(hook); - var stored = _clients.GetOrAdd(channelId, webhookClient); - if (!ReferenceEquals(stored, webhookClient)) - { - // Lost the race to cache the client (another caller cached one first); dispose the redundant one. - webhookClient.Dispose(); - } - - return stored; - } - - [LoggerMessage(Level = LogLevel.Warning, Message = "Posting a team chat line to channel {ChannelId} failed.")] - private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId); -} diff --git a/src/RustPlusBot.Features.Chat/Webhooks/ITeamChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs similarity index 57% rename from src/RustPlusBot.Features.Chat/Webhooks/ITeamChatWebhookPoster.cs rename to src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs index 45e5cbf2..fa64255b 100644 --- a/src/RustPlusBot.Features.Chat/Webhooks/ITeamChatWebhookPoster.cs +++ b/src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs @@ -1,13 +1,21 @@ +using RustPlusBot.Abstractions.Chat; + namespace RustPlusBot.Features.Chat.Webhooks; -/// Posts an in-game team chat line into a Discord #teamchat channel as the player (via a webhook). -public interface ITeamChatWebhookPoster +/// Posts an in-game chat line into its Discord chat channel as the player (via a webhook). +public interface IChatWebhookPoster { /// Posts to channel impersonating . + /// The in-game chat channel the line came from. /// The target Discord channel snowflake. /// The webhook display name (the in-game player's Steam name). /// The message text. /// A cancellation token. /// A task that completes when the message has been posted. - Task PostAsync(ulong channelId, string username, string message, CancellationToken cancellationToken); + Task PostAsync( + ChatChannelKind kind, + ulong channelId, + string username, + string message, + CancellationToken cancellationToken); } diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatInboundProcessorTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatInboundProcessorTests.cs new file mode 100644 index 00000000..0fd6f4ab --- /dev/null +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatInboundProcessorTests.cs @@ -0,0 +1,195 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Chat; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Chat.Inbound; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Commands; + +namespace RustPlusBot.Features.Chat.Tests; + +public sealed class ChatInboundProcessorTests +{ + private const ulong TeamChannel = 777UL; + private const ulong ClanChannel = 888UL; + private const ulong UnclaimedChannel = 555UL; + + private static readonly Guid ServerId = Guid.NewGuid(); + + private static ulong ChannelFor(ChatChannelKind kind) => kind == ChatChannelKind.Clan ? ClanChannel : TeamChannel; + + private static (ChatInboundProcessor Processor, IChatSender Sender, RelayDedupBuffer Dedup, IMuteStore Mute) + Build(ChatSendResult sendResult = ChatSendResult.Sent) + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + var dedup = new RelayDedupBuffer(clock); + var sender = Substitute.For(); + sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(sendResult); + + // Both doubles MUST have Kind stubbed explicitly: NSubstitute returns ChatChannelKind.Team (enum 0) + // by default, so an unstubbed clan double would silently behave as a second team locator. + var team = Substitute.For(); + team.Kind.Returns(ChatChannelKind.Team); + team.ResolveAsync(TeamChannel, Arg.Any()).Returns(((ulong, Guid)?)(10UL, ServerId)); + team.ResolveAsync(Arg.Is(c => c != TeamChannel), Arg.Any()) + .Returns(((ulong, Guid)?)null); + var clan = Substitute.For(); + clan.Kind.Returns(ChatChannelKind.Clan); + clan.ResolveAsync(ClanChannel, Arg.Any()).Returns(((ulong, Guid)?)(10UL, ServerId)); + clan.ResolveAsync(Arg.Is(c => c != ClanChannel), Arg.Any()) + .Returns(((ulong, Guid)?)null); + + var muteStore = Substitute.For(); + muteStore.GetMutedAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); + var scopeFactory = Substitute.For(); + var scope = Substitute.For(); + var scopeProvider = Substitute.For(); + scopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); + scope.ServiceProvider.Returns(scopeProvider); + scopeFactory.CreateScope().Returns(scope); + var processor = new ChatInboundProcessor([team, clan], sender, dedup, scopeFactory); + return (processor, sender, dedup, muteStore); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Ignores_bot_or_webhook_authors(ChatChannelKind kind) + { + var (processor, sender, _, _) = Build(); + var msg = new InboundMessage(AuthorIsBotOrWebhook: true, ChannelFor(kind), "Alice", "hi"); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Ignored, outcome); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Ignores_empty_content(ChatChannelKind kind) + { + var (processor, sender, _, _) = Build(); + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, ChannelFor(kind), "Alice", " "); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Ignored, outcome); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Formats_records_and_sends(ChatChannelKind kind) + { + var (processor, sender, dedup, _) = Build(); + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, ChannelFor(kind), "dave", "hello"); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Sent, outcome); + await sender.Received(1).SendAsync(kind, 10UL, ServerId, "[dave] hello", Arg.Any()); + + // The dedup entry must be recorded under the SAME kind the line was sent on, or the in-game echo + // is never suppressed and every relayed line is re-posted to Discord. + Assert.True(dedup.TryConsume(kind, (10UL, ServerId), "[dave] hello")); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Does_not_record_a_dedup_entry_under_the_other_kind(ChatChannelKind kind) + { + var (processor, _, dedup, _) = Build(); + var other = kind == ChatChannelKind.Clan ? ChatChannelKind.Team : ChatChannelKind.Clan; + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, ChannelFor(kind), "dave", "hello"); + + await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.False(dedup.TryConsume(other, (10UL, ServerId), "[dave] hello")); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Reports_failed_when_the_send_fails(ChatChannelKind kind) + { + var (processor, _, _, _) = Build(ChatSendResult.Failed); + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, ChannelFor(kind), "Alice", "hello"); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Failed, outcome); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Reports_failed_when_not_connected(ChatChannelKind kind) + { + var (processor, _, _, _) = Build(ChatSendResult.NotConnected); + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, ChannelFor(kind), "Alice", "hello"); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Failed, outcome); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Ignores_muted_server_without_recording_dedup(ChatChannelKind kind) + { + var (processor, sender, dedup, mute) = Build(); + mute.GetMutedAsync(10UL, ServerId, Arg.Any()).Returns(true); + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, ChannelFor(kind), "Alice", "hello"); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Ignored, outcome); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + Assert.False(dedup.TryConsume(kind, (10UL, ServerId), "[Alice] hello")); + } + + [Fact] + public async Task Routes_an_inbound_message_by_which_locator_claims_the_channel() + { + var (processor, sender, dedup, _) = Build(); + + var teamOutcome = await processor.ProcessAsync( + new InboundMessage(AuthorIsBotOrWebhook: false, TeamChannel, "dave", "hi team"), CancellationToken.None); + var clanOutcome = await processor.ProcessAsync( + new InboundMessage(AuthorIsBotOrWebhook: false, ClanChannel, "dave", "hi clan"), CancellationToken.None); + + Assert.Equal(InboundOutcome.Sent, teamOutcome); + Assert.Equal(InboundOutcome.Sent, clanOutcome); + await sender.Received(1) + .SendAsync(ChatChannelKind.Team, 10UL, ServerId, "[dave] hi team", Arg.Any()); + await sender.Received(1) + .SendAsync(ChatChannelKind.Clan, 10UL, ServerId, "[dave] hi clan", Arg.Any()); + Assert.True(dedup.TryConsume(ChatChannelKind.Team, (10UL, ServerId), "[dave] hi team")); + Assert.True(dedup.TryConsume(ChatChannelKind.Clan, (10UL, ServerId), "[dave] hi clan")); + } + + [Fact] + public async Task Ignores_an_inbound_message_in_a_channel_no_locator_claims() + { + var (processor, sender, _, _) = Build(); + var msg = new InboundMessage(AuthorIsBotOrWebhook: false, UnclaimedChannel, "Alice", "hi"); + + var outcome = await processor.ProcessAsync(msg, CancellationToken.None); + + Assert.Equal(InboundOutcome.Ignored, outcome); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +} diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs index dfb1a946..b65179ba 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs @@ -17,60 +17,97 @@ namespace RustPlusBot.Features.Chat.Tests; public sealed class ChatRegistrationTests { + private const ulong TeamChannel = 777UL; + private const ulong ClanChannel = 888UL; + [Fact] public async Task Services_resolve() { - await using var provider = BuildProvider(out _, out _, out _); + await using var provider = BuildProvider(out _, out _, out _, out _); - Assert.NotNull(provider.GetRequiredService()); - Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); Assert.Contains(provider.GetServices(), h => h is ChatHostedService); } + [Fact] + public async Task Relay_serves_both_locator_kinds() + { + const ulong guild = 10UL; + var server = Guid.NewGuid(); + + await using var provider = BuildProvider(out var team, out var clan, out _, out var poster); + team.GetChannelIdAsync(guild, server, Arg.Any()).Returns((ulong?)TeamChannel); + clan.GetChannelIdAsync(guild, server, Arg.Any()).Returns((ulong?)ClanChannel); + + var relay = provider.GetRequiredService(); + + await relay.RelayAsync( + new RelayedChatLine(ChatChannelKind.Team, guild, server, "Bob", "hi team", FromActivePlayer: false), + CancellationToken.None); + await relay.RelayAsync( + new RelayedChatLine(ChatChannelKind.Clan, guild, server, "Bob", "hi clan", FromActivePlayer: false), + CancellationToken.None); + + await poster.Received(1) + .PostAsync(ChatChannelKind.Team, TeamChannel, "Bob", "hi team", Arg.Any()); + await poster.Received(1) + .PostAsync(ChatChannelKind.Clan, ClanChannel, "Bob", "hi clan", Arg.Any()); + } + [Fact] public async Task Relay_and_processor_share_the_dedup_buffer() { const ulong guild = 10UL; - const ulong channelId = 777UL; var server = Guid.NewGuid(); - await using var provider = BuildProvider(out var locator, out var sender, out var poster); - locator.ResolveAsync(channelId, Arg.Any()).Returns(((ulong, Guid)?)(guild, server)); - locator.GetChannelIdAsync(guild, server, Arg.Any()).Returns((ulong?)channelId); + await using var provider = BuildProvider(out var team, out var clan, out var sender, out var poster); + team.ResolveAsync(TeamChannel, Arg.Any()).Returns(((ulong, Guid)?)(guild, server)); + team.GetChannelIdAsync(guild, server, Arg.Any()).Returns((ulong?)TeamChannel); + clan.ResolveAsync(ClanChannel, Arg.Any()).Returns(((ulong, Guid)?)(guild, server)); + clan.GetChannelIdAsync(guild, server, Arg.Any()).Returns((ulong?)ClanChannel); sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ChatSendResult.Sent); - var processor = provider.GetRequiredService(); - var relay = provider.GetRequiredService(); + var processor = provider.GetRequiredService(); + var relay = provider.GetRequiredService(); // The processor records the exact relayed line "[Alice] hi" into the dedup buffer it was constructed with. - await processor.ProcessAsync(new InboundMessage(false, channelId, "Alice", "hi"), CancellationToken.None); + await processor.ProcessAsync(new InboundMessage(false, TeamChannel, "Alice", "hi"), CancellationToken.None); // The bot player's echo of that same line must be dropped by the relay — which only happens if the // relay was constructed with the SAME buffer instance (a per-service buffer would miss and post). await relay.RelayAsync( - new TeamMessageReceivedEvent(guild, server, 555UL, "BotPlayer", "[Alice] hi", FromActivePlayer: true), + new RelayedChatLine(ChatChannelKind.Team, guild, server, "BotPlayer", "[Alice] hi", + FromActivePlayer: true), CancellationToken.None); - await poster.DidNotReceive() - .PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); } private static ServiceProvider BuildProvider( - out IChatChannelLocator locator, + out IChatChannelLocator teamLocator, + out IChatChannelLocator clanLocator, out IChatSender sender, - out ITeamChatWebhookPoster poster) + out IChatWebhookPoster poster) { - locator = Substitute.For(); + // Kind is stubbed explicitly on both doubles: NSubstitute returns Team (enum 0) by default, so an + // unstubbed clan double would register as a second team locator and the relay would never route clan. + teamLocator = Substitute.For(); + teamLocator.Kind.Returns(ChatChannelKind.Team); + clanLocator = Substitute.For(); + clanLocator.Kind.Returns(ChatChannelKind.Clan); sender = Substitute.For(); - poster = Substitute.For(); + poster = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(new DiscordSocketClient()); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(locator); + services.AddSingleton(teamLocator); + services.AddSingleton(clanLocator); services.AddSingleton(sender); services.AddLogging(); diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs new file mode 100644 index 00000000..b06fca66 --- /dev/null +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs @@ -0,0 +1,260 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Chat; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Chat.Relaying; +using RustPlusBot.Features.Chat.Webhooks; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Commands; + +namespace RustPlusBot.Features.Chat.Tests; + +public sealed class ChatRelayTests +{ + private const ulong TeamChannel = 777UL; + private const ulong ClanChannel = 888UL; + + private static ulong ChannelFor(ChatChannelKind kind) => kind == ChatChannelKind.Clan ? ClanChannel : TeamChannel; + + private static (ChatRelay Relay, IChatWebhookPoster Poster, RelayDedupBuffer Dedup, + IChatChannelLocator Team, IChatChannelLocator Clan) + Build(string prefix = "!") + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + var dedup = new RelayDedupBuffer(clock); + var poster = Substitute.For(); + + // Both doubles MUST have Kind stubbed explicitly: NSubstitute returns ChatChannelKind.Team (enum 0) + // by default, so an unstubbed clan double would silently behave as a second team locator. + var team = Substitute.For(); + team.Kind.Returns(ChatChannelKind.Team); + team.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ulong?)TeamChannel); + var clan = Substitute.For(); + clan.Kind.Returns(ChatChannelKind.Clan); + clan.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ulong?)ClanChannel); + + var muteStore = Substitute.For(); + muteStore.GetPrefixAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(prefix); + var scopeFactory = Substitute.For(); + var scope = Substitute.For(); + var scopeProvider = Substitute.For(); + scopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); + scope.ServiceProvider.Returns(scopeProvider); + scopeFactory.CreateScope().Returns(scope); + var relay = new ChatRelay([team, clan], poster, dedup, scopeFactory); + return (relay, poster, dedup, team, clan); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Posts_a_normal_message_via_webhook(ChatChannelKind kind) + { + var (relay, poster, _, _, _) = Build(); + var line = new RelayedChatLine(kind, 10UL, Guid.Empty, "Bob", "hello", FromActivePlayer: false); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.Received(1).PostAsync(kind, ChannelFor(kind), "Bob", "hello", Arg.Any()); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Drops_a_bot_prefixed_line_from_the_active_player(ChatChannelKind kind) + { + var (relay, poster, _, _, _) = Build(); + var line = new RelayedChatLine(kind, 10UL, Guid.Empty, "BotPlayer", "[R+] Cargo Ship entered the map", + FromActivePlayer: true); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Keeps_a_bot_prefixed_line_from_another_player(ChatChannelKind kind) + { + var (relay, poster, _, _, _) = Build(); + var line = new RelayedChatLine(kind, 10UL, Guid.Empty, "Bob", "[R+] hi", FromActivePlayer: false); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.Received(1).PostAsync(kind, ChannelFor(kind), "Bob", "[R+] hi", Arg.Any()); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Drops_our_own_echo(ChatChannelKind kind) + { + var (relay, poster, dedup, _, _) = Build(); + dedup.Record(kind, (10UL, Guid.Empty), "[Alice] hello"); + var echo = new RelayedChatLine(kind, 10UL, Guid.Empty, "BotPlayer", "[Alice] hello", FromActivePlayer: true); + + await relay.RelayAsync(echo, CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Posts_an_active_player_line_that_is_not_an_echo(ChatChannelKind kind) + { + var (relay, poster, _, _, _) = Build(); + var line = new RelayedChatLine(kind, 10UL, Guid.Empty, "BotPlayer", "genuine", FromActivePlayer: true); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.Received(1).PostAsync(kind, ChannelFor(kind), "BotPlayer", "genuine", Arg.Any()); + } + + [Theory] + [InlineData(ChatChannelKind.Team, true)] + [InlineData(ChatChannelKind.Team, false)] + [InlineData(ChatChannelKind.Clan, true)] + [InlineData(ChatChannelKind.Clan, false)] + public async Task Drops_a_command_invocation(ChatChannelKind kind, bool fromActivePlayer) + { + var (relay, poster, _, _, _) = Build(); + var line = new RelayedChatLine(kind, 10UL, Guid.Empty, "Bob", "!pop", fromActivePlayer); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Ignores_leading_whitespace_when_matching_the_command_prefix(ChatChannelKind kind) + { + var (relay, poster, _, _, _) = Build(); + var line = new RelayedChatLine(kind, 10UL, Guid.Empty, "Bob", " !pop", FromActivePlayer: false); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Honors_a_custom_command_prefix(ChatChannelKind kind) + { + var (relay, poster, _, _, _) = Build(prefix: "."); + + await relay.RelayAsync(new RelayedChatLine(kind, 10UL, Guid.Empty, "Bob", ".pop", false), + CancellationToken.None); + await relay.RelayAsync(new RelayedChatLine(kind, 10UL, Guid.Empty, "Bob", "!not a command", false), + CancellationToken.None); + + await poster.Received(1) + .PostAsync(kind, ChannelFor(kind), "Bob", "!not a command", Arg.Any()); + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + ".pop", Arg.Any()); + } + + [Theory] + [InlineData(ChatChannelKind.Team)] + [InlineData(ChatChannelKind.Clan)] + public async Task Does_nothing_when_the_channel_is_not_provisioned(ChatChannelKind kind) + { + var (relay, poster, _, team, clan) = Build(); + team.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns((ulong?)null); + clan.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns((ulong?)null); + var line = new RelayedChatLine(kind, 10UL, Guid.Empty, "Bob", "hello", FromActivePlayer: false); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task A_team_dedup_entry_does_not_suppress_a_clan_line() + { + var (relay, poster, dedup, _, _) = Build(); + dedup.Record(ChatChannelKind.Team, (10UL, Guid.Empty), "[Alice] hello"); + var line = new RelayedChatLine(ChatChannelKind.Clan, 10UL, Guid.Empty, "BotPlayer", "[Alice] hello", + FromActivePlayer: true); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.Received(1).PostAsync(ChatChannelKind.Clan, ClanChannel, "BotPlayer", "[Alice] hello", + Arg.Any()); + } + + [Fact] + public async Task A_clan_dedup_entry_does_not_suppress_a_team_line() + { + var (relay, poster, dedup, _, _) = Build(); + dedup.Record(ChatChannelKind.Clan, (10UL, Guid.Empty), "[Alice] hello"); + var line = new RelayedChatLine(ChatChannelKind.Team, 10UL, Guid.Empty, "BotPlayer", "[Alice] hello", + FromActivePlayer: true); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.Received(1).PostAsync(ChatChannelKind.Team, TeamChannel, "BotPlayer", "[Alice] hello", + Arg.Any()); + } + + [Fact] + public async Task Posts_a_clan_line_to_the_clan_channel_not_the_team_channel() + { + var (relay, poster, _, team, _) = Build(); + var line = new RelayedChatLine(ChatChannelKind.Clan, 10UL, Guid.Empty, "Bob", "hello", + FromActivePlayer: false); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.Received(1) + .PostAsync(ChatChannelKind.Clan, ClanChannel, "Bob", "hello", Arg.Any()); + await poster.DidNotReceive().PostAsync(Arg.Any(), TeamChannel, Arg.Any(), + Arg.Any(), Arg.Any()); + await team.DidNotReceive().GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Posts_a_team_line_to_the_team_channel_not_the_clan_channel() + { + var (relay, poster, _, _, clan) = Build(); + var line = new RelayedChatLine(ChatChannelKind.Team, 10UL, Guid.Empty, "Bob", "hello", + FromActivePlayer: false); + + await relay.RelayAsync(line, CancellationToken.None); + + await poster.Received(1) + .PostAsync(ChatChannelKind.Team, TeamChannel, "Bob", "hello", Arg.Any()); + await clan.DidNotReceive().GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public void Uses_the_clan_webhook_name_for_clan_lines() + { + // The poster re-discovers its webhook by name on restart, so these strings are load-bearing: + // changing one orphans every webhook already provisioned in live guilds. + Assert.Equal("RustPlusBot ClanChat", WebhookNameFor(ChatChannelKind.Clan)); + Assert.Equal("RustPlusBot TeamChat", WebhookNameFor(ChatChannelKind.Team)); + } + + private static string WebhookNameFor(ChatChannelKind kind) + { + var method = typeof(DiscordChatWebhookPoster).GetMethod( + "WebhookNameFor", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + return (string)method.Invoke(null, [kind])!; + } +} diff --git a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs index 4fa7fb6d..9f348cf7 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs @@ -12,40 +12,52 @@ using RustPlusBot.Features.Chat.Webhooks; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Clans; using RustPlusBot.Persistence.Commands; namespace RustPlusBot.Features.Chat.Tests.Hosting; public sealed class ChatHostedServiceTests { - private static (ChatHostedService Service, InMemoryEventBus Bus, ITeamChatWebhookPoster Poster, - IChatChannelLocator Locator) + private const ulong TeamChannel = 555UL; + private const ulong ClanChannel = 666UL; + + private static (ChatHostedService Service, InMemoryEventBus Bus, IChatWebhookPoster Poster, IClanStore ClanStore) Build() { var clock = Substitute.For(); clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); var dedup = new RelayDedupBuffer(clock); - var poster = Substitute.For(); - var locator = Substitute.For(); - locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((ulong?)555UL); + var poster = Substitute.For(); + + // Kind is stubbed explicitly on both doubles: NSubstitute returns Team (enum 0) by default, so an + // unstubbed clan double would silently make the relay treat clan lines as team lines. + var teamLocator = Substitute.For(); + teamLocator.Kind.Returns(ChatChannelKind.Team); + teamLocator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ulong?)TeamChannel); + var clanLocator = Substitute.For(); + clanLocator.Kind.Returns(ChatChannelKind.Clan); + clanLocator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ulong?)ClanChannel); + // The relay reads the scoped IMuteStore command prefix per message; stub a scope that provides it. var muteStore = Substitute.For(); muteStore.GetPrefixAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns("!"); - var relayScopeFactory = Substitute.For(); - var relayScope = Substitute.For(); - var relayScopeProvider = Substitute.For(); - relayScopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); - relayScope.ServiceProvider.Returns(relayScopeProvider); - relayScopeFactory.CreateScope().Returns(relayScope); - var relay = new TeamChatRelay([locator], poster, dedup, relayScopeFactory); + var relayScopeFactory = BuildScopeFactory(muteStore, typeof(IMuteStore)); + var relay = new ChatRelay([teamLocator, clanLocator], poster, dedup, relayScopeFactory); var inboundLocator = Substitute.For(); + inboundLocator.Kind.Returns(ChatChannelKind.Team); var sender = Substitute.For(); // Processor not exercised by bus-side tests; stub scope factory is sufficient. var processor = ChatHostedServiceTestAccess.BuildProcessor(inboundLocator, sender, dedup); + // The clan loop records the sender's display name through a scoped IClanStore. + var clanStore = Substitute.For(); + var hostScopeFactory = BuildScopeFactory(clanStore, typeof(IClanStore)); + var bus = new InMemoryEventBus(); var client = new DiscordSocketClient(); var service = new ChatHostedService( @@ -53,11 +65,26 @@ private static (ChatHostedService Service, InMemoryEventBus Bus, ITeamChatWebhoo bus, relay, processor, + hostScopeFactory, NullLogger.Instance); - return (service, bus, poster, locator); + return (service, bus, poster, clanStore); + } + + private static IServiceScopeFactory BuildScopeFactory(object service, Type serviceType) + { + var scopeFactory = Substitute.For(); + var scope = Substitute.For(); + var scopeProvider = Substitute.For(); + scopeProvider.GetService(serviceType).Returns(service); + scope.ServiceProvider.Returns(scopeProvider); + scopeFactory.CreateScope().Returns(scope); + return scopeFactory; } + private static bool Posted(IChatWebhookPoster poster) => + poster.ReceivedCalls().Any(c => c.GetMethodInfo().Name == nameof(IChatWebhookPoster.PostAsync)); + [Fact] public async Task TeamMessageReceivedEvent_routes_to_relay_and_posts_to_discord() { @@ -65,16 +92,63 @@ public async Task TeamMessageReceivedEvent_routes_to_relay_and_posts_to_discord( await service.StartAsync(default); var deadline = DateTimeOffset.UtcNow.AddSeconds(20); - while (DateTimeOffset.UtcNow < deadline - && !poster.ReceivedCalls().Any(c => - c.GetMethodInfo().Name == nameof(ITeamChatWebhookPoster.PostAsync))) + while (DateTimeOffset.UtcNow < deadline && !Posted(poster)) { await bus.PublishAsync( new TeamMessageReceivedEvent(10UL, Guid.NewGuid(), 1UL, "Alice", "hello", FromActivePlayer: false)); await Task.Delay(20); } - await poster.Received().PostAsync(555UL, "Alice", "hello", Arg.Any()); + await poster.Received() + .PostAsync(ChatChannelKind.Team, TeamChannel, "Alice", "hello", Arg.Any()); + + await service.StopAsync(default); + } + + [Fact] + public async Task Relays_a_team_message_event() + { + var (service, bus, poster, _) = Build(); + await service.StartAsync(default); + + var deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline && !Posted(poster)) + { + await bus.PublishAsync( + new TeamMessageReceivedEvent(10UL, Guid.NewGuid(), 1UL, "dave", "hi team", FromActivePlayer: false)); + await Task.Delay(20); + } + + await poster.Received() + .PostAsync(ChatChannelKind.Team, TeamChannel, "dave", "hi team", Arg.Any()); + await poster.DidNotReceive().PostAsync(ChatChannelKind.Clan, Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + + await service.StopAsync(default); + } + + [Fact] + public async Task Relays_a_clan_message_event() + { + var (service, bus, poster, clanStore) = Build(); + var serverId = Guid.NewGuid(); + await service.StartAsync(default); + + var deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline && !Posted(poster)) + { + await bus.PublishAsync( + new ClanMessageReceivedEvent(10UL, serverId, 7UL, "dave", "hi clan", FromActivePlayer: false)); + await Task.Delay(20); + } + + await poster.Received() + .PostAsync(ChatChannelKind.Clan, ClanChannel, "dave", "hi clan", Arg.Any()); + await poster.DidNotReceive().PostAsync(ChatChannelKind.Team, Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + + // The clan API reports members by Steam id only, so chat is the only place names are learned. + await clanStore.Received().RecordNameAsync(10UL, serverId, 7UL, "dave", Arg.Any()); await service.StopAsync(default); } @@ -95,39 +169,39 @@ public async Task StartAsync_then_StopAsync_completes_cleanly() public async Task RelayLoop_faults_on_poster_exception_but_StopAsync_completes_cleanly() { var (service, bus, poster, _) = Build(); - poster.PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + poster.PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) .ThrowsAsync(new InvalidOperationException("simulated fault")); await service.StartAsync(default); // Publish until the relay has actually reached (and thrown from) the poster. var deadline = DateTimeOffset.UtcNow.AddSeconds(20); - while (DateTimeOffset.UtcNow < deadline - && !poster.ReceivedCalls().Any(c => - c.GetMethodInfo().Name == nameof(ITeamChatWebhookPoster.PostAsync))) + while (DateTimeOffset.UtcNow < deadline && !Posted(poster)) { await bus.PublishAsync( new TeamMessageReceivedEvent(10UL, Guid.NewGuid(), 1UL, "Bob", "boom", FromActivePlayer: false)); await Task.Delay(20); } - // The relay threw, causing the loop to fault and complete (LogRelayLoopFaulted). StopAsync joins the + // The relay threw, causing the loop to fault and complete (LogTeamRelayLoopFaulted). StopAsync joins the // faulted task cleanly — no rethrow. This is crash-isolation, not per-event resilience. - await poster.Received().PostAsync(Arg.Any(), "Bob", "boom", Arg.Any()); + await poster.Received().PostAsync(ChatChannelKind.Team, Arg.Any(), "Bob", "boom", + Arg.Any()); await service.StopAsync(default); } } -/// Provides internal access to build for tests. +/// Provides internal access to build for tests. internal static class ChatHostedServiceTestAccess { - internal static TeamChatInboundProcessor BuildProcessor( + internal static ChatInboundProcessor BuildProcessor( IChatChannelLocator locator, IChatSender sender, RelayDedupBuffer dedup) { - // A NullScopeFactory is sufficient — processor is not exercised by the bus relay path under test. - var scopeFactory = Substitute.For(); - return new TeamChatInboundProcessor([locator], sender, dedup, scopeFactory); + // A stub scope factory is sufficient — the processor is not exercised by the bus relay path under test. + var scopeFactory = Substitute.For(); + return new ChatInboundProcessor([locator], sender, dedup, scopeFactory); } } diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs deleted file mode 100644 index 5bea4662..00000000 --- a/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using RustPlusBot.Abstractions.Chat; -using RustPlusBot.Abstractions.Time; -using RustPlusBot.Features.Chat.Inbound; -using RustPlusBot.Features.Chat.Relaying; -using RustPlusBot.Features.Connections.Listening; -using RustPlusBot.Features.Workspace.Locating; -using RustPlusBot.Persistence.Commands; - -namespace RustPlusBot.Features.Chat.Tests; - -public sealed class TeamChatInboundProcessorTests -{ - private static readonly Guid ServerId = Guid.NewGuid(); - - private static (TeamChatInboundProcessor Processor, IChatSender Sender, RelayDedupBuffer Dedup, IMuteStore Mute) - Build(ChatSendResult sendResult = ChatSendResult.Sent) - { - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var dedup = new RelayDedupBuffer(clock); - var sender = Substitute.For(); - sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()) - .Returns(sendResult); - var locator = Substitute.For(); - locator.ResolveAsync(777UL, Arg.Any()).Returns(((ulong, Guid)?)(10UL, ServerId)); - locator.ResolveAsync(Arg.Is(c => c != 777UL), Arg.Any()) - .Returns(((ulong, Guid)?)null); - var muteStore = Substitute.For(); - muteStore.GetMutedAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); - var scopeFactory = Substitute.For(); - var scope = Substitute.For(); - var scopeProvider = Substitute.For(); - scopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); - scope.ServiceProvider.Returns(scopeProvider); - scopeFactory.CreateScope().Returns(scope); - var processor = new TeamChatInboundProcessor([locator], sender, dedup, scopeFactory); - return (processor, sender, dedup, muteStore); - } - - [Fact] - public async Task Ignores_bot_or_webhook_authors() - { - var (processor, sender, _, _) = Build(); - var msg = new InboundMessage(AuthorIsBotOrWebhook: true, 777UL, "Alice", "hi"); - - var outcome = await processor.ProcessAsync(msg, CancellationToken.None); - - Assert.Equal(InboundOutcome.Ignored, outcome); - await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Ignores_non_teamchat_channels() - { - var (processor, sender, _, _) = Build(); - var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 555UL, "Alice", "hi"); - - var outcome = await processor.ProcessAsync(msg, CancellationToken.None); - - Assert.Equal(InboundOutcome.Ignored, outcome); - await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Ignores_empty_content() - { - var (processor, sender, _, _) = Build(); - var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", " "); - - var outcome = await processor.ProcessAsync(msg, CancellationToken.None); - - Assert.Equal(InboundOutcome.Ignored, outcome); - await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Formats_records_and_sends() - { - var (processor, sender, dedup, _) = Build(); - var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", "hello"); - - var outcome = await processor.ProcessAsync(msg, CancellationToken.None); - - Assert.Equal(InboundOutcome.Sent, outcome); - await sender.Received(1) - .SendAsync(ChatChannelKind.Team, 10UL, ServerId, "[Alice] hello", Arg.Any()); - Assert.True(dedup.TryConsume(ChatChannelKind.Team, (10UL, ServerId), "[Alice] hello")); - } - - [Fact] - public async Task Reports_failed_when_not_connected() - { - var (processor, _, _, _) = Build(ChatSendResult.NotConnected); - var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", "hello"); - - var outcome = await processor.ProcessAsync(msg, CancellationToken.None); - - Assert.Equal(InboundOutcome.Failed, outcome); - } - - [Fact] - public async Task Ignores_muted_server() - { - var (processor, sender, _, mute) = Build(); - mute.GetMutedAsync(10UL, ServerId, Arg.Any()).Returns(true); - var msg = new InboundMessage(AuthorIsBotOrWebhook: false, 777UL, "Alice", "hello"); - - var outcome = await processor.ProcessAsync(msg, CancellationToken.None); - - Assert.Equal(InboundOutcome.Ignored, outcome); - await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()); - } -} diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs deleted file mode 100644 index 1756f36a..00000000 --- a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using RustPlusBot.Abstractions.Chat; -using RustPlusBot.Abstractions.Events; -using RustPlusBot.Abstractions.Time; -using RustPlusBot.Features.Chat.Relaying; -using RustPlusBot.Features.Chat.Webhooks; -using RustPlusBot.Features.Workspace.Locating; -using RustPlusBot.Persistence.Commands; - -namespace RustPlusBot.Features.Chat.Tests; - -public sealed class TeamChatRelayTests -{ - private static (TeamChatRelay Relay, ITeamChatWebhookPoster Poster, RelayDedupBuffer Dedup, IChatChannelLocator - Locator) - Build(string prefix = "!") - { - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - var dedup = new RelayDedupBuffer(clock); - var poster = Substitute.For(); - var locator = Substitute.For(); - locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((ulong?)777UL); - var muteStore = Substitute.For(); - muteStore.GetPrefixAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(prefix); - var scopeFactory = Substitute.For(); - var scope = Substitute.For(); - var scopeProvider = Substitute.For(); - scopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); - scope.ServiceProvider.Returns(scopeProvider); - scopeFactory.CreateScope().Returns(scope); - var relay = new TeamChatRelay([locator], poster, dedup, scopeFactory); - return (relay, poster, dedup, locator); - } - - [Fact] - public async Task Posts_a_normal_message_via_webhook() - { - var (relay, poster, _, _) = Build(); - var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", "hello", FromActivePlayer: false); - - await relay.RelayAsync(evt, CancellationToken.None); - - await poster.Received(1).PostAsync(777UL, "Bob", "hello", Arg.Any()); - } - - [Fact] - public async Task Drops_our_own_echo() - { - var (relay, poster, dedup, _) = Build(); - var key = (10UL, Guid.Empty); - dedup.Record(ChatChannelKind.Team, key, "[Alice] hello"); - var echo = new TeamMessageReceivedEvent(10UL, Guid.Empty, 555UL, "BotPlayer", "[Alice] hello", - FromActivePlayer: true); - - await relay.RelayAsync(echo, CancellationToken.None); - - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task Posts_active_player_message_that_is_not_an_echo() - { - var (relay, poster, _, _) = Build(); - var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 555UL, "BotPlayer", "genuine", FromActivePlayer: true); - - await relay.RelayAsync(evt, CancellationToken.None); - - await poster.Received(1).PostAsync(777UL, "BotPlayer", "genuine", Arg.Any()); - } - - [Fact] - public async Task Skips_when_channel_not_provisioned() - { - var (relay, poster, _, locator) = Build(); - locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((ulong?)null); - var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", "hello", FromActivePlayer: false); - - await relay.RelayAsync(evt, CancellationToken.None); - - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task Drops_bot_originated_echo_by_prefix() - { - var (relay, poster, _, _) = Build(); - var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 555UL, "BotPlayer", "[R+] Cargo Ship entered the map", - FromActivePlayer: true); - - await relay.RelayAsync(evt, CancellationToken.None); - - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task Posts_teammate_message_even_with_prefix() - { - var (relay, poster, _, _) = Build(); - var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", "[R+] hi", FromActivePlayer: false); - - await relay.RelayAsync(evt, CancellationToken.None); - - await poster.Received(1).PostAsync(777UL, "Bob", "[R+] hi", Arg.Any()); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task Drops_command_shaped_player_lines(bool fromActivePlayer) - { - var (relay, poster, _, _) = Build(); - var evt = new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", "!pop", fromActivePlayer); - - await relay.RelayAsync(evt, CancellationToken.None); - - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task Command_drop_honors_custom_prefix() - { - var (relay, poster, _, _) = Build(prefix: "."); - await relay.RelayAsync(new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", ".pop", false), - CancellationToken.None); - await relay.RelayAsync(new TeamMessageReceivedEvent(10UL, Guid.Empty, 999UL, "Bob", "!not a command", false), - CancellationToken.None); - - await poster.Received(1).PostAsync(777UL, "Bob", "!not a command", Arg.Any()); - await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), ".pop", - Arg.Any()); - } -} From dd85385a90d2985d6057b679afb6d0af490d1e6c Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 00:47:11 +0200 Subject: [PATCH 18/39] Narrow the cancellation-guard constraint to fallback-returning methods The rule was written for socket probes that must distinguish an internal timeout token from a caller token. Applied to hosted-service consumer loops it is actively harmful: those own their CTS, already catch OperationCanceledException first, and are joined by a StopAsync that only swallows OCE, so the guard would let a post-cancellation exception fault shutdown. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-21-clan-support.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-21-clan-support.md b/docs/superpowers/plans/2026-07-21-clan-support.md index 0170235e..89084c61 100644 --- a/docs/superpowers/plans/2026-07-21-clan-support.md +++ b/docs/superpowers/plans/2026-07-21-clan-support.md @@ -21,7 +21,8 @@ Every task's requirements implicitly include this section. - `CA1305`/`CA1307`/`CA1310`: pass `CultureInfo.InvariantCulture` on every format/parse and `StringComparison.Ordinal` on every string comparison. - `CA2007`: `.ConfigureAwait(false)` on every awaited task in `src/`. Test projects are exempt. - Broad `catch (Exception)` is allowed ONLY with an inline `#pragma warning disable CA1031` carrying a one-line justification comment, paired with a `[LoggerMessage]`-generated `static partial` log method. -- **Any broad catch in a method that takes a `CancellationToken` MUST be guarded `catch (Exception ex) when (!cancellationToken.IsCancellationRequested)`**, so caller-initiated cancellation propagates instead of being logged as a fault and swallowed into a fallback value. Every timeout-guarded method in `RustPlusSocketSource` already does this — match it. +- **A broad catch in a method that takes a CALLER-supplied `CancellationToken` AND returns a fallback value on failure MUST be guarded `catch (Exception ex) when (!cancellationToken.IsCancellationRequested)`**, so caller-initiated cancellation propagates instead of being logged as a fault and swallowed into that fallback. Every timeout-guarded method in `RustPlusSocketSource` does this — match it. + **This rule does NOT apply to hosted-service consumer loops**, which own their own `CancellationTokenSource`, already catch `OperationCanceledException` in a preceding clause, and are joined by a `StopAsync` that only swallows OCE. Adding the guard there would let a non-OCE exception thrown after cancellation escape and fault host shutdown. Leave those catches unguarded. - Tests: plain xUnit `Assert.*` + NSubstitute. **No FluentAssertions.** `using Xunit` is a global using — never add it per file. Tests do not need `.ConfigureAwait(false)`. - Every new string key MUST be added to **both** `src/RustPlusBot.Localization/Strings.resx` and `Strings.fr.resx`. `StringsResourceParityTests` fails the build otherwise. - `dotnet jb cleanupcode RustPlusBot.slnx --profile="ReformatAndReorder"` is a hard CI gate that fails on any diff. It is slow — run it ONCE at the very end (Task 13), not per task. From 18e2a16457a289b456baa22b62117e4a3cc99471 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 00:51:40 +0200 Subject: [PATCH 19/39] Apply Task 7 chat bridge review findings Isolate the clan display-name recording failure so it can no longer kill the clan relay loop; replace reflection-based webhook name pinning with an internal method and move its test to a dedicated file; log a warning when ChatRelay has no locator for a channel kind instead of failing silently; remove a subsumed duplicate hosted-service test. The redundant VSTHRD003 pragma flagged in the review turned out to be load-bearing (removing it reintroduces the warning under TreatWarningsAsErrors), so it was left in place. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Hosting/ChatHostedService.cs | 26 ++++++++++++++----- .../Relaying/ChatRelay.cs | 11 ++++++-- .../Webhooks/DiscordChatWebhookPoster.cs | 2 +- .../ChatRelayTests.cs | 22 ++-------------- .../DiscordChatWebhookPosterTests.cs | 16 ++++++++++++ .../Hosting/ChatHostedServiceTests.cs | 23 ++-------------- 6 files changed, 50 insertions(+), 50 deletions(-) create mode 100644 tests/RustPlusBot.Features.Chat.Tests/DiscordChatWebhookPosterTests.cs diff --git a/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs b/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs index 98fd228f..e64646ac 100644 --- a/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs +++ b/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs @@ -103,13 +103,24 @@ private async Task ConsumeClanMessagesAsync(CancellationToken cancellationToken) await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) .ConfigureAwait(false)) { - // Clan members arrive as Steam ids only; chat is where we learn their names. - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) + // Clan members arrive as Steam ids only; chat is where we learn their names. A failure here + // is isolated so it cannot take down the relay loop below: a missed name is cosmetic, a + // dead relay loop silences clan chat until the process restarts. + try { - var store = scope.ServiceProvider.GetRequiredService(); - await store.RecordNameAsync(evt.GuildId, evt.ServerId, evt.SenderSteamId, evt.SenderName, - cancellationToken).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + await store.RecordNameAsync(evt.GuildId, evt.ServerId, evt.SenderSteamId, evt.SenderName, + cancellationToken).ConfigureAwait(false); + } + } +#pragma warning disable CA1031 // Broad catch: a name-recording failure must not kill the clan relay loop. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogClanNameRecordFailed(logger, ex); } await relay.RelayAsync( @@ -164,6 +175,9 @@ private async Task OnMessageReceivedAsync(SocketMessage message) [LoggerMessage(Level = LogLevel.Error, Message = "Clan message relay loop faulted.")] private static partial void LogClanRelayLoopFaulted(ILogger logger, Exception exception); + [LoggerMessage(Level = LogLevel.Warning, Message = "Recording the clan sender's display name failed.")] + private static partial void LogClanNameRecordFailed(ILogger logger, Exception exception); + [LoggerMessage(Level = LogLevel.Error, Message = "Chat inbound listener faulted.")] private static partial void LogListenerFaulted(ILogger logger, Exception exception); } diff --git a/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs index 646ece8b..c35a4b16 100644 --- a/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs +++ b/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Features.Chat.Webhooks; using RustPlusBot.Features.Connections.Listening; @@ -15,11 +16,13 @@ namespace RustPlusBot.Features.Chat.Relaying; /// Posts the line via webhook. /// Tracks lines the bridge relayed into the game so their echoes can be dropped. /// Opens a scope to read the scoped command prefix. -internal sealed class ChatRelay( +/// The logger. +internal sealed partial class ChatRelay( IEnumerable locators, IChatWebhookPoster poster, RelayDedupBuffer dedup, - IServiceScopeFactory scopeFactory) + IServiceScopeFactory scopeFactory, + ILogger logger) { private readonly Dictionary _locators = locators.ToDictionary(l => l.Kind); @@ -44,6 +47,7 @@ public async Task RelayAsync(RelayedChatLine line, CancellationToken cancellatio if (!_locators.TryGetValue(line.Kind, out var locator)) { + LogNoLocatorForKind(logger, line.Kind); return; } @@ -79,4 +83,7 @@ private async Task GetCommandPrefixAsync(ulong guildId, Guid serverId, C return await muteStore.GetPrefixAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); } } + + [LoggerMessage(Level = LogLevel.Warning, Message = "No chat channel locator is registered for kind {Kind}.")] + private static partial void LogNoLocatorForKind(ILogger logger, ChatChannelKind kind); } diff --git a/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs index 065a9d1a..d892dc02 100644 --- a/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs +++ b/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs @@ -67,7 +67,7 @@ await webhook.SendMessageAsync(message, username: username, allowedMentions: All /// /// The channel kind. /// The webhook name to find or create. - private static string WebhookNameFor(ChatChannelKind kind) => kind switch + internal static string WebhookNameFor(ChatChannelKind kind) => kind switch { ChatChannelKind.Clan => "RustPlusBot ClanChat", _ => "RustPlusBot TeamChat", diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs index b06fca66..50a9127b 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs @@ -1,5 +1,5 @@ -using System.Reflection; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using RustPlusBot.Abstractions.Chat; using RustPlusBot.Abstractions.Time; @@ -45,7 +45,7 @@ private static (ChatRelay Relay, IChatWebhookPoster Poster, RelayDedupBuffer Ded scopeProvider.GetService(typeof(IMuteStore)).Returns(muteStore); scope.ServiceProvider.Returns(scopeProvider); scopeFactory.CreateScope().Returns(scope); - var relay = new ChatRelay([team, clan], poster, dedup, scopeFactory); + var relay = new ChatRelay([team, clan], poster, dedup, scopeFactory, NullLogger.Instance); return (relay, poster, dedup, team, clan); } @@ -239,22 +239,4 @@ await poster.Received(1) .PostAsync(ChatChannelKind.Team, TeamChannel, "Bob", "hello", Arg.Any()); await clan.DidNotReceive().GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()); } - - [Fact] - public void Uses_the_clan_webhook_name_for_clan_lines() - { - // The poster re-discovers its webhook by name on restart, so these strings are load-bearing: - // changing one orphans every webhook already provisioned in live guilds. - Assert.Equal("RustPlusBot ClanChat", WebhookNameFor(ChatChannelKind.Clan)); - Assert.Equal("RustPlusBot TeamChat", WebhookNameFor(ChatChannelKind.Team)); - } - - private static string WebhookNameFor(ChatChannelKind kind) - { - var method = typeof(DiscordChatWebhookPoster).GetMethod( - "WebhookNameFor", - BindingFlags.NonPublic | BindingFlags.Static); - Assert.NotNull(method); - return (string)method.Invoke(null, [kind])!; - } } diff --git a/tests/RustPlusBot.Features.Chat.Tests/DiscordChatWebhookPosterTests.cs b/tests/RustPlusBot.Features.Chat.Tests/DiscordChatWebhookPosterTests.cs new file mode 100644 index 00000000..5e8e3272 --- /dev/null +++ b/tests/RustPlusBot.Features.Chat.Tests/DiscordChatWebhookPosterTests.cs @@ -0,0 +1,16 @@ +using RustPlusBot.Abstractions.Chat; +using RustPlusBot.Features.Chat.Webhooks; + +namespace RustPlusBot.Features.Chat.Tests; + +public sealed class DiscordChatWebhookPosterTests +{ + [Fact] + public void Uses_the_clan_webhook_name_for_clan_lines() + { + // The poster re-discovers its webhook by name on restart, so these strings are load-bearing: + // changing one orphans every webhook already provisioned in live guilds. + Assert.Equal("RustPlusBot ClanChat", DiscordChatWebhookPoster.WebhookNameFor(ChatChannelKind.Clan)); + Assert.Equal("RustPlusBot TeamChat", DiscordChatWebhookPoster.WebhookNameFor(ChatChannelKind.Team)); + } +} diff --git a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs index 9f348cf7..034266f0 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs @@ -46,7 +46,8 @@ private static (ChatHostedService Service, InMemoryEventBus Bus, IChatWebhookPos var muteStore = Substitute.For(); muteStore.GetPrefixAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns("!"); var relayScopeFactory = BuildScopeFactory(muteStore, typeof(IMuteStore)); - var relay = new ChatRelay([teamLocator, clanLocator], poster, dedup, relayScopeFactory); + var relay = new ChatRelay([teamLocator, clanLocator], poster, dedup, relayScopeFactory, + NullLogger.Instance); var inboundLocator = Substitute.For(); inboundLocator.Kind.Returns(ChatChannelKind.Team); @@ -85,26 +86,6 @@ private static IServiceScopeFactory BuildScopeFactory(object service, Type servi private static bool Posted(IChatWebhookPoster poster) => poster.ReceivedCalls().Any(c => c.GetMethodInfo().Name == nameof(IChatWebhookPoster.PostAsync)); - [Fact] - public async Task TeamMessageReceivedEvent_routes_to_relay_and_posts_to_discord() - { - var (service, bus, poster, _) = Build(); - await service.StartAsync(default); - - var deadline = DateTimeOffset.UtcNow.AddSeconds(20); - while (DateTimeOffset.UtcNow < deadline && !Posted(poster)) - { - await bus.PublishAsync( - new TeamMessageReceivedEvent(10UL, Guid.NewGuid(), 1UL, "Alice", "hello", FromActivePlayer: false)); - await Task.Delay(20); - } - - await poster.Received() - .PostAsync(ChatChannelKind.Team, TeamChannel, "Alice", "hello", Arg.Any()); - - await service.StopAsync(default); - } - [Fact] public async Task Relays_a_team_message_event() { From 762ec924db42e51ce9ee83023f76f76220a71c7e Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 01:04:13 +0200 Subject: [PATCH 20/39] Add the clan snapshot differ Compare consecutive clan snapshots into an ordered, deterministic change list. A first snapshot and a switch to a different clan both re-baseline silently rather than announcing every existing member, and an invite-to-member transition is reported once as an acceptance. Scaffolds the RustPlusBot.Features.Clans project and its test project, mirroring the Features.Chat project structure, and wires both into the solution file. Co-Authored-By: Claude Opus 4.8 (1M context) --- RustPlusBot.slnx | 2 + .../RustPlusBot.Features.Clans.csproj | 12 + .../State/ClanChange.cs | 59 ++++ .../State/ClanSnapshotDiffer.cs | 125 ++++++++ .../RustPlusBot.Features.Clans.Tests.csproj | 19 ++ .../State/ClanSnapshotDifferTests.cs | 302 ++++++++++++++++++ 6 files changed, 519 insertions(+) create mode 100644 src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj create mode 100644 src/RustPlusBot.Features.Clans/State/ClanChange.cs create mode 100644 src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs create mode 100644 tests/RustPlusBot.Features.Clans.Tests/RustPlusBot.Features.Clans.Tests.csproj create mode 100644 tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx index 032a6480..6527b33a 100644 --- a/RustPlusBot.slnx +++ b/RustPlusBot.slnx @@ -10,6 +10,7 @@ + @@ -39,6 +40,7 @@ + diff --git a/src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj b/src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj new file mode 100644 index 00000000..00f0365e --- /dev/null +++ b/src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/RustPlusBot.Features.Clans/State/ClanChange.cs b/src/RustPlusBot.Features.Clans/State/ClanChange.cs new file mode 100644 index 00000000..361c4ea6 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/State/ClanChange.cs @@ -0,0 +1,59 @@ +namespace RustPlusBot.Features.Clans.State; + +/// The kind of clan change detected between two snapshots. +internal enum ClanChangeKind +{ + /// The clan was dissolved, or the player left it. + Dissolved = 0, + + /// The clan was renamed. + Renamed = 1, + + /// The message of the day changed. + MotdChanged = 2, + + /// A member joined the clan. + MemberJoined = 3, + + /// A member is no longer in the clan. The API cannot distinguish leaving from being kicked. + MemberLeft = 4, + + /// A member moved to a higher-standing role. + MemberPromoted = 5, + + /// A member moved to a lower-standing role. + MemberDemoted = 6, + + /// A player was invited to the clan. + InviteSent = 7, + + /// An invited player joined, observed as a single invite-to-member transition. + InviteAccepted = 8, + + /// An invitation went away without the player joining. + InviteRevoked = 9, + + /// The clan logo changed. + LogoChanged = 10, + + /// The clan colour changed. + ColorChanged = 11, + + /// The clan score changed. + ScoreChanged = 12, +} + +/// One detected clan change, ready to be rendered into the #claninfo feed. +/// What changed. +/// The player the change is about, or null for clan-wide changes. +/// The player who caused the change (MOTD author, recruiter), or null. +/// Free text carried by the change (the new name or the new MOTD), or null. +/// The new role name for a promotion or demotion, or null. +/// The new score for a score change, or null. +internal sealed record ClanChange( + ClanChangeKind Kind, + ulong? SteamId = null, + ulong? ActorSteamId = null, + string? Text = null, + string? RoleName = null, + long? Score = null); diff --git a/src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs b/src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs new file mode 100644 index 00000000..f878be12 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs @@ -0,0 +1,125 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Features.Clans.State; + +/// +/// Compares two clan snapshots and produces the feed events between them. Pure: no I/O, no clock, +/// no state. Emission order is fixed so the feed reads consistently. +/// +internal static class ClanSnapshotDiffer +{ + /// Diffs two snapshots. + /// The last known snapshot, or null when none was stored. + /// The new snapshot, or null when the clan is gone. + /// The detected changes, in a fixed order. + public static IReadOnlyList Diff(ClanSnapshot? previous, ClanSnapshot? current) + { + if (previous is null) + { + // A first snapshot is a baseline, not news: announcing every existing member as a + // "joined" would spam a brand-new channel on first detection. + return []; + } + + if (current is null) + { + return [new ClanChange(ClanChangeKind.Dissolved, Text: previous.Name)]; + } + + if (previous.ClanId != current.ClanId) + { + // A different clan entirely; re-baseline rather than diffing unrelated rosters. + return []; + } + + var changes = new List(); + + if (!string.Equals(previous.Name, current.Name, StringComparison.Ordinal)) + { + changes.Add(new ClanChange(ClanChangeKind.Renamed, Text: current.Name)); + } + + if (!string.Equals(previous.Motd, current.Motd, StringComparison.Ordinal)) + { + changes.Add(new ClanChange(ClanChangeKind.MotdChanged, ActorSteamId: current.MotdAuthor, + Text: current.Motd)); + } + + var previousMembers = previous.Members.ToDictionary(m => m.SteamId); + var currentMembers = current.Members.ToDictionary(m => m.SteamId); + var previousInvites = previous.Invites.Select(i => i.SteamId).ToHashSet(); + var currentInvites = current.Invites.Select(i => i.SteamId).ToHashSet(); + + // An id that left Invites and appeared in Members in one step is an acceptance, reported + // once — never as a join plus a revocation. + var accepted = previousInvites + .Where(id => !currentInvites.Contains(id) && currentMembers.ContainsKey(id) && !previousMembers.ContainsKey(id)) + .ToHashSet(); + + foreach (var id in currentMembers.Keys.Where(id => !previousMembers.ContainsKey(id) && !accepted.Contains(id)) + .Order()) + { + changes.Add(new ClanChange(ClanChangeKind.MemberJoined, id)); + } + + foreach (var id in previousMembers.Keys.Where(id => !currentMembers.ContainsKey(id)).Order()) + { + changes.Add(new ClanChange(ClanChangeKind.MemberLeft, id)); + } + + var rolesById = current.Roles.ToDictionary(r => r.RoleId); + foreach (var (id, member) in currentMembers.OrderBy(kv => kv.Key)) + { + if (!previousMembers.TryGetValue(id, out var before) || before.RoleId == member.RoleId) + { + continue; + } + + if (!rolesById.TryGetValue(member.RoleId, out var newRole) || + !rolesById.TryGetValue(before.RoleId, out var oldRole)) + { + // An unresolvable role change is not worth guessing a direction over. + continue; + } + + // Lower Rank is higher standing. + var kind = newRole.Rank < oldRole.Rank ? ClanChangeKind.MemberPromoted : ClanChangeKind.MemberDemoted; + changes.Add(new ClanChange(kind, id, RoleName: newRole.Name)); + } + + foreach (var invite in current.Invites.Where(i => !previousInvites.Contains(i.SteamId)) + .OrderBy(i => i.SteamId)) + { + changes.Add(new ClanChange(ClanChangeKind.InviteSent, invite.SteamId, invite.Recruiter)); + } + + foreach (var id in accepted.Order()) + { + changes.Add(new ClanChange(ClanChangeKind.InviteAccepted, id)); + } + + foreach (var id in previousInvites + .Where(id => !currentInvites.Contains(id) && !accepted.Contains(id)) + .Order()) + { + changes.Add(new ClanChange(ClanChangeKind.InviteRevoked, id)); + } + + if (!string.Equals(previous.LogoHash, current.LogoHash, StringComparison.Ordinal)) + { + changes.Add(new ClanChange(ClanChangeKind.LogoChanged)); + } + + if (previous.Color != current.Color) + { + changes.Add(new ClanChange(ClanChangeKind.ColorChanged)); + } + + if (previous.Score != current.Score) + { + changes.Add(new ClanChange(ClanChangeKind.ScoreChanged, Score: current.Score)); + } + + return changes; + } +} diff --git a/tests/RustPlusBot.Features.Clans.Tests/RustPlusBot.Features.Clans.Tests.csproj b/tests/RustPlusBot.Features.Clans.Tests/RustPlusBot.Features.Clans.Tests.csproj new file mode 100644 index 00000000..c99b147b --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/RustPlusBot.Features.Clans.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs new file mode 100644 index 00000000..6fbb791c --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs @@ -0,0 +1,302 @@ +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Clans.State; + +namespace RustPlusBot.Features.Clans.Tests.State; + +public sealed class ClanSnapshotDifferTests +{ + private static ClanSnapshot Clan( + long clanId = 1, + string name = "Wolves", + string? motd = null, + ulong? motdAuthor = null, + string? logoHash = null, + int? color = null, + long? score = null, + IReadOnlyList? roles = null, + IReadOnlyList? members = null, + IReadOnlyList? invites = null) => new( + clanId, + name, + DateTimeOffset.UnixEpoch, + 1, + motd, + motd is null ? null : DateTimeOffset.UnixEpoch, + motdAuthor, + logoHash, + color, + null, + score, + roles ?? [Role(1, 0, "Member")], + members ?? [], + invites ?? []); + + private static ClanRoleSnapshot Role(int roleId, int rank, string name) => + new(roleId, rank, name, false, false, false, false, false, false, false, false, false); + + private static ClanMemberSnapshot Member(ulong steamId, int roleId = 1, bool online = false) => + new(steamId, roleId, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, null, online); + + private static ClanInviteSnapshot Invite(ulong steamId, ulong recruiter = 99) => + new(steamId, recruiter, DateTimeOffset.UnixEpoch); + + [Fact] + public void Emits_nothing_for_a_first_snapshot() + { + var current = Clan(members: [Member(10), Member(20)], invites: [Invite(30)]); + + var result = ClanSnapshotDiffer.Diff(null, current); + + Assert.Empty(result); + } + + [Fact] + public void Emits_nothing_when_both_snapshots_are_null() + { + var result = ClanSnapshotDiffer.Diff(null, null); + + Assert.Empty(result); + } + + [Fact] + public void Emits_nothing_when_nothing_changed() + { + var previous = Clan(score: 100, members: [Member(10)], invites: [Invite(20)]); + var current = Clan(score: 100, members: [Member(10)], invites: [Invite(20)]); + + var result = ClanSnapshotDiffer.Diff(previous, current); + + Assert.Empty(result); + } + + [Fact] + public void Emits_dissolved_when_the_clan_goes_away() + { + var previous = Clan(name: "Wolves"); + + var result = ClanSnapshotDiffer.Diff(previous, null); + + var change = Assert.Single(result); + Assert.Equal(ClanChangeKind.Dissolved, change.Kind); + Assert.Equal("Wolves", change.Text); + } + + [Fact] + public void Emits_nothing_when_the_player_joined_a_different_clan() + { + var previous = Clan(clanId: 1, members: [Member(10)]); + var current = Clan(clanId: 2, members: [Member(20)]); + + var result = ClanSnapshotDiffer.Diff(previous, current); + + Assert.Empty(result); + } + + [Fact] + public void Detects_a_rename() + { + var previous = Clan(name: "Wolves"); + var current = Clan(name: "Dire Wolves"); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.Renamed, change.Kind); + Assert.Equal("Dire Wolves", change.Text); + } + + [Fact] + public void Detects_a_motd_change_and_carries_the_author() + { + var previous = Clan(motd: null); + var current = Clan(motd: "gg all", motdAuthor: 42); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.MotdChanged, change.Kind); + Assert.Equal("gg all", change.Text); + Assert.Equal((ulong?)42, change.ActorSteamId); + } + + [Fact] + public void Detects_a_member_joining() + { + var previous = Clan(members: []); + var current = Clan(members: [Member(10)]); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.MemberJoined, change.Kind); + Assert.Equal((ulong?)10, change.SteamId); + } + + [Fact] + public void Detects_a_member_leaving() + { + var previous = Clan(members: [Member(10)]); + var current = Clan(members: []); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.MemberLeft, change.Kind); + Assert.Equal((ulong?)10, change.SteamId); + } + + [Fact] + public void Detects_a_promotion_using_rank_order() + { + IReadOnlyList roles = [Role(1, 2, "Member"), Role(2, 0, "Leader")]; + var previous = Clan(roles: roles, members: [Member(10, roleId: 1)]); + var current = Clan(roles: roles, members: [Member(10, roleId: 2)]); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.MemberPromoted, change.Kind); + Assert.Equal((ulong?)10, change.SteamId); + Assert.Equal("Leader", change.RoleName); + } + + [Fact] + public void Detects_a_demotion_using_rank_order() + { + IReadOnlyList roles = [Role(1, 0, "Leader"), Role(2, 2, "Member")]; + var previous = Clan(roles: roles, members: [Member(10, roleId: 1)]); + var current = Clan(roles: roles, members: [Member(10, roleId: 2)]); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.MemberDemoted, change.Kind); + Assert.Equal((ulong?)10, change.SteamId); + Assert.Equal("Member", change.RoleName); + } + + [Fact] + public void Emits_no_role_change_when_the_new_role_is_unknown() + { + IReadOnlyList roles = [Role(1, 0, "Leader")]; + var previous = Clan(roles: roles, members: [Member(10, roleId: 1)]); + var current = Clan(roles: roles, members: [Member(10, roleId: 2)]); + + var result = ClanSnapshotDiffer.Diff(previous, current); + + Assert.Empty(result); + } + + [Fact] + public void Detects_an_invite_being_sent() + { + var previous = Clan(invites: []); + var current = Clan(invites: [Invite(30, recruiter: 5)]); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.InviteSent, change.Kind); + Assert.Equal((ulong?)30, change.SteamId); + Assert.Equal((ulong?)5, change.ActorSteamId); + } + + [Fact] + public void Detects_an_invite_being_revoked() + { + var previous = Clan(invites: [Invite(30)]); + var current = Clan(invites: []); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.InviteRevoked, change.Kind); + Assert.Equal((ulong?)30, change.SteamId); + } + + [Fact] + public void Reports_an_accepted_invite_once_and_not_as_a_join() + { + var previous = Clan(members: [], invites: [Invite(7)]); + var current = Clan(members: [Member(7)], invites: []); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.InviteAccepted, change.Kind); + Assert.Equal((ulong?)7, change.SteamId); + } + + [Fact] + public void Detects_a_logo_change() + { + var previous = Clan(logoHash: "abc123"); + var current = Clan(logoHash: "def456"); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.LogoChanged, change.Kind); + } + + [Fact] + public void Detects_a_colour_change() + { + var previous = Clan(color: 0xFF0000); + var current = Clan(color: 0x00FF00); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.ColorChanged, change.Kind); + } + + [Fact] + public void Detects_a_score_change_and_carries_the_new_score() + { + var previous = Clan(score: 100); + var current = Clan(score: 250); + + var change = Assert.Single(ClanSnapshotDiffer.Diff(previous, current)); + + Assert.Equal(ClanChangeKind.ScoreChanged, change.Kind); + Assert.Equal((long?)250, change.Score); + } + + [Fact] + public void Orders_changes_deterministically() + { + // Changes name, motd, members (a join, a leave and a promotion), invites (a send, an + // acceptance and a revocation), logo, colour and score all at once, and asserts that the + // emitted Kind sequence matches the documented order exactly. + IReadOnlyList roles = [Role(1, 2, "Member"), Role(2, 0, "Leader")]; + var previous = Clan( + name: "Wolves", + motd: "old motd", + motdAuthor: 1, + logoHash: "logo1", + color: 100, + score: 500, + roles: roles, + members: [Member(1, roleId: 1), Member(2, roleId: 1), Member(3, roleId: 1)], + invites: [Invite(50), Invite(60)]); + var current = Clan( + name: "Dire Wolves", + motd: "new motd", + motdAuthor: 2, + logoHash: "logo2", + color: 200, + score: 999, + roles: roles, + members: [Member(2, roleId: 2), Member(3, roleId: 1), Member(4, roleId: 1), Member(60, roleId: 1)], + invites: [Invite(70)]); + + var result = ClanSnapshotDiffer.Diff(previous, current); + + var kinds = result.Select(c => c.Kind).ToArray(); + ClanChangeKind[] expected = + [ + ClanChangeKind.Renamed, + ClanChangeKind.MotdChanged, + ClanChangeKind.MemberJoined, + ClanChangeKind.MemberLeft, + ClanChangeKind.MemberPromoted, + ClanChangeKind.InviteSent, + ClanChangeKind.InviteAccepted, + ClanChangeKind.InviteRevoked, + ClanChangeKind.LogoChanged, + ClanChangeKind.ColorChanged, + ClanChangeKind.ScoreChanged, + ]; + Assert.Equal(expected, kinds); + } +} From f48a4b00e763e2458058e25d7364c735e82b72aa Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 01:05:14 +0200 Subject: [PATCH 21/39] Correct the clan plan's expected test counts The counts still assumed the superseded Task 7, which created a clan chat bridge inside Features.Clans. That bridge now lives in Features.Chat, so the Features.Clans.Tests baseline is 19, not 36, and every later task's expected total shifts down by 17. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-21-clan-support.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-07-21-clan-support.md b/docs/superpowers/plans/2026-07-21-clan-support.md index 89084c61..c344de47 100644 --- a/docs/superpowers/plans/2026-07-21-clan-support.md +++ b/docs/superpowers/plans/2026-07-21-clan-support.md @@ -2619,7 +2619,7 @@ internal static class ClanSnapshotDiffer - [ ] **Step 5: Run the tests** Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` -Expected: PASS, 36 tests total (17 from Task 7 + 19 here). +Expected: PASS, 19 tests. This is a brand-new test project, so confirm the assembly reports 19 and not 0 — a build failure in a new project reports zero tests and looks green. - [ ] **Step 6: Commit** @@ -2837,7 +2837,7 @@ internal static class ClanComponentIds - [ ] **Step 8: Run the tests** Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` -Expected: PASS, 56 total (36 + 4 resolver + 16 renderer). +Expected: PASS, 39 total in `Features.Clans.Tests` (19 + 4 resolver + 16 renderer). - [ ] **Step 9: Commit** @@ -3014,7 +3014,7 @@ Register the assembly for module discovery in Task 11's DI, via `services.AddSin - [ ] **Step 5: Run the tests** Run: `dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` -Expected: PASS, 63 total. +Expected: PASS, 46 total in `Features.Clans.Tests`. Run: `dotnet build RustPlusBot.slnx -maxcpucount:1` Expected: `0 Error(s)`. @@ -3247,7 +3247,7 @@ public static IServiceCollection AddClans(this IServiceCollection services) - [ ] **Step 9: Run the tests** Run: `dtk dotnet test tests/RustPlusBot.Features.Clans.Tests -maxcpucount:1` -Expected: PASS, 74 total (63 + 8 state + 3 registration). +Expected: PASS, 57 total in `Features.Clans.Tests` (46 + 8 state + 3 registration). Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` Expected: `0 Error(s)`. @@ -3363,7 +3363,7 @@ Expected: the host reaches Discord login. A `ValidateOnStart` or DI resolution f Run: `dotnet test RustPlusBot.slnx -maxcpucount:1` -Expected: every assembly passes. **Read the per-assembly counts.** `RustPlusBot.Features.Clans.Tests` must report **74**, not 0. An assembly reporting 0 means it failed to build and its tests silently did not run. +Expected: every assembly passes. **Read the per-assembly counts.** `RustPlusBot.Features.Clans.Tests` must report **57**, not 0. An assembly reporting 0 means it failed to build and its tests silently did not run. Record the actual per-assembly numbers in the commit message or PR body — not "all tests pass". From 2e8794f16961bc0045cb89c4a0c07906b9c1ee9f Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 01:16:43 +0200 Subject: [PATCH 22/39] Add tests pinning sub-ordering and the old-role guard branch in ClanSnapshotDiffer Close two coverage gaps found in review of Task 8: deleting the differ's .Order()/.OrderBy() calls previously left all 19 tests green because every fixture already declared its collections in ascending id order and each output category had at most one entry. New tests use descending-order fixtures for MemberJoined, MemberLeft and InviteSent so the sort is actually observable, plus a test for the untested "old role id unresolvable" half of the role-change OR-guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../State/ClanSnapshotDifferTests.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs index 6fbb791c..42430f0a 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs @@ -141,6 +141,32 @@ public void Detects_a_member_leaving() Assert.Equal((ulong?)10, change.SteamId); } + [Fact] + public void Orders_joined_members_ascending_by_steam_id_regardless_of_input_order() + { + var previous = Clan(members: []); + var current = Clan(members: [Member(50), Member(10), Member(30)]); + + var result = ClanSnapshotDiffer.Diff(previous, current); + + Assert.All(result, c => Assert.Equal(ClanChangeKind.MemberJoined, c.Kind)); + ulong?[] expected = [10, 30, 50]; + Assert.Equal(expected, [.. result.Select(c => c.SteamId)]); + } + + [Fact] + public void Orders_left_members_ascending_by_steam_id_regardless_of_input_order() + { + var previous = Clan(members: [Member(50), Member(10), Member(30)]); + var current = Clan(members: []); + + var result = ClanSnapshotDiffer.Diff(previous, current); + + Assert.All(result, c => Assert.Equal(ClanChangeKind.MemberLeft, c.Kind)); + ulong?[] expected = [10, 30, 50]; + Assert.Equal(expected, [.. result.Select(c => c.SteamId)]); + } + [Fact] public void Detects_a_promotion_using_rank_order() { @@ -181,6 +207,19 @@ public void Emits_no_role_change_when_the_new_role_is_unknown() Assert.Empty(result); } + [Fact] + public void Emits_no_role_change_when_the_old_role_is_unknown() + { + // The member's previous role id (1) does not exist in current.Roles, while the new role + // id (2) does. The direction can't be judged without both endpoints, so nothing is emitted. + var previous = Clan(members: [Member(10, roleId: 1)]); + var current = Clan(roles: [Role(2, 0, "Leader")], members: [Member(10, roleId: 2)]); + + var result = ClanSnapshotDiffer.Diff(previous, current); + + Assert.Empty(result); + } + [Fact] public void Detects_an_invite_being_sent() { @@ -194,6 +233,19 @@ public void Detects_an_invite_being_sent() Assert.Equal((ulong?)5, change.ActorSteamId); } + [Fact] + public void Orders_sent_invites_ascending_by_steam_id_regardless_of_input_order() + { + var previous = Clan(invites: []); + var current = Clan(invites: [Invite(50), Invite(10), Invite(30)]); + + var result = ClanSnapshotDiffer.Diff(previous, current); + + Assert.All(result, c => Assert.Equal(ClanChangeKind.InviteSent, c.Kind)); + ulong?[] expected = [10, 30, 50]; + Assert.Equal(expected, [.. result.Select(c => c.SteamId)]); + } + [Fact] public void Detects_an_invite_being_revoked() { From 7bdcaaa78861cc0563fc64b4b26fa15f35b6ecad Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 01:31:38 +0200 Subject: [PATCH 23/39] Render the clan overview, roster and invites embeds Add the three anchored #claninfo embeds and the name resolver behind them, which falls back to a Steam profile link for ids we have never seen a name for. The invites embed renders empty when there are none so the reconciler skips posting it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ClanComponentIds.cs | 14 + .../Messages/ClanInvitesMessageRenderer.cs | 85 ++++ .../Messages/ClanOverviewMessageRenderer.cs | 197 +++++++++ .../Messages/ClanRosterMessageRenderer.cs | 182 +++++++++ .../Names/ClanNameResolver.cs | 45 +++ .../Names/IClanNameResolver.cs | 25 ++ .../RustPlusBot.Features.Clans.csproj | 7 + .../Messages/ClanRendererTests.cs | 375 ++++++++++++++++++ .../Names/ClanNameResolverTests.cs | 73 ++++ 9 files changed, 1003 insertions(+) create mode 100644 src/RustPlusBot.Features.Clans/ClanComponentIds.cs create mode 100644 src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs create mode 100644 src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs create mode 100644 src/RustPlusBot.Features.Clans/Messages/ClanRosterMessageRenderer.cs create mode 100644 src/RustPlusBot.Features.Clans/Names/ClanNameResolver.cs create mode 100644 src/RustPlusBot.Features.Clans/Names/IClanNameResolver.cs create mode 100644 tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs create mode 100644 tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs diff --git a/src/RustPlusBot.Features.Clans/ClanComponentIds.cs b/src/RustPlusBot.Features.Clans/ClanComponentIds.cs new file mode 100644 index 00000000..e7c91cae --- /dev/null +++ b/src/RustPlusBot.Features.Clans/ClanComponentIds.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Features.Clans; + +/// Stable Discord component ids for the clan surfaces. +internal static class ClanComponentIds +{ + /// Prefix of the Set MOTD button id; the server id is appended. + public const string SetMotdButtonPrefix = "clan:motd:"; + + /// Prefix of the Set MOTD modal id; the server id is appended. + public const string SetMotdModalPrefix = "clan:motdmodal:"; + + /// Id of the MOTD text input inside the modal. + public const string MotdInputId = "clan:motd:text"; +} diff --git a/src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs b/src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs new file mode 100644 index 00000000..936f631d --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs @@ -0,0 +1,85 @@ +using System.Globalization; +using System.Text; +using Discord; +using RustPlusBot.Features.Clans.Names; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.Messages; + +/// +/// Renders the anchored #claninfo pending-invites embed. Renders empty when there are no pending +/// invites so the reconciler never posts the message at all. +/// +/// Supplies the stored clan snapshot. +/// Resolves invitee and recruiter Steam ids to display names. +/// String resolution. +public sealed class ClanInvitesMessageRenderer( + IClanStore store, + IClanNameResolver names, + ILocalizer localizer) : IMessageRenderer +{ + /// The message key this renderer produces. + public const string Key = "clan.invites"; + + /// + public string MessageKey => Key; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + if (context.ServerId is not Guid serverId) + { + return new MessagePayload(null, null, null); + } + + var clan = await store.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (clan is null || clan.Invites.Count == 0) + { + // See ClanOverviewMessageRenderer: an empty payload keeps this key inert, both on clanless + // servers and when there is simply nothing pending. + return new MessagePayload(null, null, null); + } + + var culture = context.Culture; + + // One batched call covering both sides of every invite. + var ids = new HashSet(); + foreach (var invite in clan.Invites) + { + ids.Add(invite.SteamId); + ids.Add(invite.Recruiter); + } + + var resolved = await names.ResolveAsync(context.GuildId, serverId, ids, cancellationToken) + .ConfigureAwait(false); + + var body = new StringBuilder(); + foreach (var invite in clan.Invites.OrderBy(i => i.Timestamp)) + { + body.Append(localizer.Get("clan.invites.line", culture, + Name(resolved, invite.SteamId), + Name(resolved, invite.Recruiter), + TimestampTag.FromDateTimeOffset(invite.Timestamp, TimestampTagStyles.Relative).ToString())) + .Append('\n'); + } + + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("clan.invites.title", culture, + clan.Invites.Count.ToString(CultureInfo.InvariantCulture))) + .WithColor(Color.Gold) + .WithDescription(body.ToString().TrimEnd('\n')) + .Build(); + + return new MessagePayload(null, embed, null); + } + + private static string Name(IReadOnlyDictionary resolved, ulong steamId) => + resolved.TryGetValue(steamId, out var name) + ? name + : steamId.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs b/src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs new file mode 100644 index 00000000..f2f1839d --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs @@ -0,0 +1,197 @@ +using System.Globalization; +using Discord; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Clans.Names; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Clans; +using RustPlusBot.Persistence.Connections; + +namespace RustPlusBot.Features.Clans.Messages; + +/// +/// Renders the anchored #claninfo overview embed: name, score, member count, age, leadership and +/// the message of the day, plus a Set MOTD button for players whose clan role allows it. +/// +/// Supplies the stored clan snapshot. +/// Resolves member Steam ids to display names. +/// Supplies the server's active player credential. +/// String resolution. +public sealed class ClanOverviewMessageRenderer( + IClanStore store, + IClanNameResolver names, + IConnectionStore connections, + ILocalizer localizer) : IMessageRenderer +{ + /// The message key this renderer produces. + public const string Key = "clan.overview"; + + /// + public string MessageKey => Key; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + if (context.ServerId is not Guid serverId) + { + return new MessagePayload(null, null, null); + } + + var clan = await store.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (clan is null) + { + // The clan channel only exists while a clan does, so there is no provisioned message to + // edit here. An empty payload keeps this key inert on clanless servers. + return new MessagePayload(null, null, null); + } + + var culture = context.Culture; + var leader = LeaderOf(clan); + var resolved = await ResolveNamesAsync(context.GuildId, serverId, clan, leader, cancellationToken) + .ConfigureAwait(false); + + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("clan.overview.title", culture, clan.Name)) + .WithColor(ColorFor(clan.Color)) + .AddField(localizer.Get("clan.overview.score", culture), + clan.Score?.ToString(CultureInfo.InvariantCulture) + ?? localizer.Get("clan.overview.unknown", culture)) + .AddField(localizer.Get("clan.overview.members", culture), MemberCount(clan)) + .AddField(localizer.Get("clan.overview.created", culture), Relative(clan.Created)); + + if (leader is not null) + { + embed.AddField(localizer.Get("clan.overview.leader", culture), Name(resolved, leader.SteamId)); + } + + if (leader is null || leader.SteamId != clan.Creator) + { + embed.AddField(localizer.Get("clan.overview.creator", culture), Name(resolved, clan.Creator)); + } + + embed.AddField(localizer.Get("clan.overview.motd", culture), Motd(clan, resolved, culture)); + + var components = await BuildComponentsAsync(context.GuildId, serverId, clan, culture, cancellationToken) + .ConfigureAwait(false); + + return new MessagePayload(null, embed.Build(), components); + } + + /// The member holding the lowest-Rank role, or null when no member has a known role. + /// The clan snapshot. + /// The leading member, or null. + private static ClanMemberSnapshot? LeaderOf(ClanSnapshot clan) + { + var ranks = clan.Roles.ToDictionary(r => r.RoleId, r => r.Rank); + return clan.Members + .Where(m => ranks.ContainsKey(m.RoleId)) + .OrderBy(m => ranks[m.RoleId]) + .ThenBy(m => m.Joined) + .FirstOrDefault(); + } + + private static Color ColorFor(int? argb) => + argb is { } packed ? new Color((uint)packed & 0x00FFFFFFu) : Color.DarkGrey; + + private static string Relative(DateTimeOffset moment) => + TimestampTag.FromDateTimeOffset(moment, TimestampTagStyles.Relative).ToString(); + + private static string Name(IReadOnlyDictionary resolved, ulong steamId) => + resolved.TryGetValue(steamId, out var name) + ? name + : steamId.ToString(CultureInfo.InvariantCulture); + + private static string MemberCount(ClanSnapshot clan) + { + var count = clan.Members.Count.ToString(CultureInfo.InvariantCulture); + return clan.MaxMemberCount is { } max + ? string.Create(CultureInfo.InvariantCulture, $"{count}/{max}") + : count; + } + + private Task> ResolveNamesAsync( + ulong guildId, + Guid serverId, + ClanSnapshot clan, + ClanMemberSnapshot? leader, + CancellationToken cancellationToken) + { + // One batched call: the embed needs at most the leader, the creator and the MOTD author. + var ids = new HashSet { clan.Creator }; + if (leader is not null) + { + ids.Add(leader.SteamId); + } + + if (clan.MotdAuthor is { } author) + { + ids.Add(author); + } + + return names.ResolveAsync(guildId, serverId, ids, cancellationToken); + } + + private string Motd(ClanSnapshot clan, IReadOnlyDictionary resolved, string culture) + { + if (string.IsNullOrWhiteSpace(clan.Motd)) + { + return localizer.Get("clan.overview.nomotd", culture); + } + + if (clan.MotdAuthor is not { } author || clan.MotdTimestamp is not { } changed) + { + return clan.Motd; + } + + var by = localizer.Get("clan.overview.motdby", culture, Name(resolved, author), Relative(changed)); + return string.Create(CultureInfo.InvariantCulture, $"{clan.Motd}\n{by}"); + } + + /// + /// Builds the Set MOTD button, but only when the server's active player is a clan member whose + /// role carries CanSetMotd. Showing an action the API will reject is worse than hiding it. + /// + /// The owning guild snowflake. + /// The server id. + /// The clan snapshot. + /// The guild culture. + /// A cancellation token. + /// The components, or null when the button must be hidden. + private async Task BuildComponentsAsync( + ulong guildId, + Guid serverId, + ClanSnapshot clan, + string culture, + CancellationToken cancellationToken) + { + var credential = await connections.GetActiveCredentialAsync(guildId, serverId, cancellationToken) + .ConfigureAwait(false); + if (credential is null) + { + return null; + } + + var member = clan.Members.FirstOrDefault(m => m.SteamId == credential.SteamId); + if (member is null) + { + return null; + } + + var role = clan.Roles.FirstOrDefault(r => r.RoleId == member.RoleId); + if (role is not { CanSetMotd: true }) + { + return null; + } + + return new ComponentBuilder() + .WithButton( + localizer.Get("clan.overview.setmotd", culture), + $"{ClanComponentIds.SetMotdButtonPrefix}{serverId}", + ButtonStyle.Primary, + row: 0) + .Build(); + } +} diff --git a/src/RustPlusBot.Features.Clans/Messages/ClanRosterMessageRenderer.cs b/src/RustPlusBot.Features.Clans/Messages/ClanRosterMessageRenderer.cs new file mode 100644 index 00000000..8310abf8 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Messages/ClanRosterMessageRenderer.cs @@ -0,0 +1,182 @@ +using System.Globalization; +using Discord; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Clans.Names; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.Messages; + +/// +/// Renders the anchored #claninfo roster embed: one field per clan role (in rank order) listing +/// that role's members with presence, tenure and officer notes. +/// +/// Supplies the stored clan snapshot. +/// Resolves member Steam ids to display names. +/// String resolution. +public sealed class ClanRosterMessageRenderer( + IClanStore store, + IClanNameResolver names, + ILocalizer localizer) : IMessageRenderer +{ + /// The message key this renderer produces. + public const string Key = "clan.roster"; + + /// Discord's hard cap on the length of an embed field value. + private const int FieldValueLimit = 1024; + + /// + public string MessageKey => Key; + + /// + public async ValueTask RenderAsync(MessageRenderContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + if (context.ServerId is not Guid serverId) + { + return new MessagePayload(null, null, null); + } + + var clan = await store.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (clan is null) + { + // See ClanOverviewMessageRenderer: an empty payload keeps this key inert on clanless servers. + return new MessagePayload(null, null, null); + } + + var culture = context.Culture; + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("clan.roster.title", culture, + clan.Members.Count.ToString(CultureInfo.InvariantCulture))) + .WithColor(ColorFor(clan.Color)); + + if (clan.Members.Count == 0) + { + return new MessagePayload(null, + embed.WithDescription(localizer.Get("clan.roster.empty", culture)).Build(), null); + } + + // One batched call for the whole roster rather than one per member. + var resolved = await names + .ResolveAsync(context.GuildId, serverId, clan.Members.Select(m => m.SteamId).ToHashSet(), + cancellationToken) + .ConfigureAwait(false); + + var roles = clan.Roles.ToDictionary(r => r.RoleId); + foreach (var role in clan.Roles.OrderBy(r => r.Rank).ThenBy(r => r.RoleId)) + { + var members = Ordered(clan.Members.Where(m => m.RoleId == role.RoleId)).ToList(); + if (members.Count == 0) + { + continue; + } + + embed.AddField(Header(role, culture), Body(members, resolved, culture)); + } + + var orphans = Ordered(clan.Members.Where(m => !roles.ContainsKey(m.RoleId))).ToList(); + if (orphans.Count > 0) + { + embed.AddField(localizer.Get("clan.roster.unknownrole", culture), Body(orphans, resolved, culture)); + } + + return new MessagePayload(null, embed.Build(), null); + } + + private static Color ColorFor(int? argb) => + argb is { } packed ? new Color((uint)packed & 0x00FFFFFFu) : Color.DarkGrey; + + private static string Relative(DateTimeOffset moment) => + TimestampTag.FromDateTimeOffset(moment, TimestampTagStyles.Relative).ToString(); + + /// Orders a role's members: online first, then by how long they have been in the clan. + /// The members to order. + /// The ordered members. + private static IEnumerable Ordered(IEnumerable members) => + members.OrderByDescending(m => m.Online).ThenBy(m => m.Joined); + + /// Builds the field name: the role name followed by the permissions it actually grants. + /// The role. + /// The guild culture. + /// The field name. + private string Header(ClanRoleSnapshot role, string culture) + { + var flags = new List(); + Add(role.CanSetMotd, "setmotd"); + Add(role.CanSetLogo, "setlogo"); + Add(role.CanInvite, "invite"); + Add(role.CanKick, "kick"); + Add(role.CanPromote, "promote"); + Add(role.CanDemote, "demote"); + Add(role.CanSetPlayerNotes, "setplayernotes"); + Add(role.CanAccessLogs, "accesslogs"); + Add(role.CanAccessScoreEvents, "accessscoreevents"); + + return flags.Count == 0 + ? role.Name + : string.Create(CultureInfo.InvariantCulture, $"{role.Name} — {string.Join(" · ", flags)}"); + + void Add(bool granted, string flag) + { + if (granted) + { + flags.Add(localizer.Get($"clan.roster.perm.{flag}", culture)); + } + } + } + + private string Body( + IReadOnlyList members, + IReadOnlyDictionary resolved, + string culture) + { + var lines = members.Select(m => Line(m, resolved, culture)).ToList(); + var full = string.Join('\n', lines); + if (full.Length <= FieldValueLimit) + { + return full; + } + + // Discord rejects a field value over 1024 characters, and silently dropping members would + // misrepresent the roster; truncate at a line boundary and say how many were omitted. + for (var kept = lines.Count - 1; kept > 0; kept--) + { + var candidate = string.Create(CultureInfo.InvariantCulture, + $"{string.Join('\n', lines.Take(kept))}\n{Truncated(lines.Count - kept, culture)}"); + if (candidate.Length <= FieldValueLimit) + { + return candidate; + } + } + + return Truncated(lines.Count, culture); + } + + private string Truncated(int omitted, string culture) => + localizer.Get("clan.roster.truncated", culture, omitted.ToString(CultureInfo.InvariantCulture)); + + private string Line( + ClanMemberSnapshot member, + IReadOnlyDictionary resolved, + string culture) + { + var name = resolved.TryGetValue(member.SteamId, out var known) + ? known + : member.SteamId.ToString(CultureInfo.InvariantCulture); + + var state = member.Online + ? localizer.Get("clan.roster.joined", culture, Relative(member.Joined)) + : localizer.Get("clan.roster.lastseen", culture, Relative(member.LastSeen)); + + var glyph = member.Online ? "🟢" : "⚫"; + var line = string.Create(CultureInfo.InvariantCulture, $"{glyph} {name} {state}"); + + return string.IsNullOrWhiteSpace(member.Notes) + ? line + : string.Create(CultureInfo.InvariantCulture, + $"{line} {localizer.Get("clan.roster.notes", culture, member.Notes)}"); + } +} diff --git a/src/RustPlusBot.Features.Clans/Names/ClanNameResolver.cs b/src/RustPlusBot.Features.Clans/Names/ClanNameResolver.cs new file mode 100644 index 00000000..fddd38c4 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Names/ClanNameResolver.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.Names; + +/// +/// Resolves clan member Steam ids to display names. The clan API reports members by id only, so +/// names are harvested from clan chat and team snapshots; an id we have never seen a name for +/// renders as a Steam profile link rather than a bare number. +/// +/// Supplies the cached names. +internal sealed class ClanNameResolver(IClanStore store) : IClanNameResolver +{ + /// + public async Task> ResolveAsync( + ulong guildId, + Guid serverId, + IReadOnlyCollection steamIds, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(steamIds); + if (steamIds.Count == 0) + { + return new Dictionary(); + } + + var known = await store.GetNamesAsync(guildId, serverId, steamIds, cancellationToken).ConfigureAwait(false); + + var result = new Dictionary(steamIds.Count); + foreach (var id in steamIds) + { + result[id] = known.TryGetValue(id, out var name) && !string.IsNullOrWhiteSpace(name) + ? name + : ProfileLink(id); + } + + return result; + } + + private static string ProfileLink(ulong steamId) + { + var id = steamId.ToString(CultureInfo.InvariantCulture); + return string.Create(CultureInfo.InvariantCulture, $"[{id}](https://steamcommunity.com/profiles/{id})"); + } +} diff --git a/src/RustPlusBot.Features.Clans/Names/IClanNameResolver.cs b/src/RustPlusBot.Features.Clans/Names/IClanNameResolver.cs new file mode 100644 index 00000000..6eddf1eb --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Names/IClanNameResolver.cs @@ -0,0 +1,25 @@ +namespace RustPlusBot.Features.Clans.Names; + +/// +/// Resolves clan member Steam ids to display names for rendering. The interface is public because +/// the clan renderers take it on their (public) constructors, which the DI container must be able +/// to invoke. +/// +public interface IClanNameResolver +{ + /// Resolves display names for every requested Steam id. + /// The owning guild snowflake. + /// The server id. + /// The Steam64 ids to resolve. + /// A cancellation token. + /// + /// A map covering every requested id: known ids map to their cached display name, and + /// ids we have never seen a name for map to a Steam profile markdown link, so callers never + /// have to null-check. + /// + Task> ResolveAsync( + ulong guildId, + Guid serverId, + IReadOnlyCollection steamIds, + CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj b/src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj index 00f0365e..c226fea2 100644 --- a/src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj +++ b/src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj @@ -7,6 +7,13 @@ + + + + + + + diff --git a/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs b/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs new file mode 100644 index 00000000..c46a2e8c --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs @@ -0,0 +1,375 @@ +using System.Globalization; +using Discord; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Features.Clans.Messages; +using RustPlusBot.Features.Clans.Names; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Clans; +using RustPlusBot.Persistence.Connections; + +namespace RustPlusBot.Features.Clans.Tests.Messages; + +public sealed class ClanRendererTests +{ + private const ulong Guild = 42UL; + + private static readonly Guid Server = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + private static readonly MessageRenderContext Context = new(Guild, Server, "en"); + + // ----- Overview ------------------------------------------------------------------------- + + [Fact] + public async Task Overview_returns_an_empty_payload_without_a_server_id() + { + var store = Substitute.For(); + var renderer = new ClanOverviewMessageRenderer(store, Resolver(), Substitute.For(), + Localizer()); + + var payload = await renderer.RenderAsync(new MessageRenderContext(Guild, null, "en"), CancellationToken.None); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Overview_returns_an_empty_payload_when_no_clan_is_stored() + { + var store = Substitute.For(); + store.GetAsync(Guild, Server, Arg.Any()).Returns(Task.FromResult(null)); + var renderer = new ClanOverviewMessageRenderer(store, Resolver(), Substitute.For(), + Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Overview_shows_the_clan_name_score_and_member_count() + { + var clan = Clan( + name: "Wolves", + maxMemberCount: 8, + score: 1234, + members: [Member(1UL, 1), Member(2UL, 2)]); + var renderer = new ClanOverviewMessageRenderer(Store(clan), Resolver(), Substitute.For(), + Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + Assert.Contains("clan.overview.title", payload.Embed.Title, StringComparison.Ordinal); + Assert.Contains("Wolves", payload.Embed.Title, StringComparison.Ordinal); + Assert.Equal("1234", FieldValue(payload.Embed, "clan.overview.score")); + Assert.Equal("2/8", FieldValue(payload.Embed, "clan.overview.members")); + } + + [Fact] + public async Task Overview_shows_the_motd_and_its_author() + { + var clan = Clan( + motd: "Hold the fort", + motdAuthor: 2UL, + members: [Member(1UL, 1), Member(2UL, 2)]); + var resolver = Resolver(new Dictionary { [2UL] = "Grace" }); + var renderer = new ClanOverviewMessageRenderer(Store(clan), resolver, Substitute.For(), + Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + var motd = FieldValue(payload.Embed, "clan.overview.motd"); + Assert.Contains("Hold the fort", motd, StringComparison.Ordinal); + Assert.Contains("clan.overview.motdby", motd, StringComparison.Ordinal); + Assert.Contains("Grace", motd, StringComparison.Ordinal); + } + + [Fact] + public async Task Overview_uses_the_clan_colour_when_set() + { + // Packed ARGB: the alpha byte must be masked off before it reaches Discord. + var clan = Clan(color: unchecked((int)0xFF336699)); + var renderer = new ClanOverviewMessageRenderer(Store(clan), Resolver(), Substitute.For(), + Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + Assert.Equal(new Color(0x336699u), payload.Embed.Color); + } + + [Fact] + public async Task Overview_shows_the_set_motd_button_when_the_active_player_may_set_it() + { + var clan = Clan( + roles: [Role(1, 0, "Leader", canSetMotd: true)], + members: [Member(7UL, 1)]); + var renderer = new ClanOverviewMessageRenderer(Store(clan), Resolver(), Connections(7UL), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Components); + var button = payload.Components.Components.OfType() + .SelectMany(r => r.Components) + .OfType() + .Single(); + Assert.Equal( + "clan:motd:" + Server.ToString("D", CultureInfo.InvariantCulture), + button.CustomId); + } + + [Fact] + public async Task Overview_hides_the_set_motd_button_when_the_active_player_may_not() + { + var clan = Clan( + roles: [Role(1, 0, "Member")], + members: [Member(7UL, 1)]); + var renderer = new ClanOverviewMessageRenderer(Store(clan), Resolver(), Connections(7UL), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.Null(payload.Components); + } + + [Fact] + public async Task Overview_hides_the_set_motd_button_when_the_active_player_is_not_a_member() + { + var clan = Clan( + roles: [Role(1, 0, "Leader", canSetMotd: true)], + members: [Member(7UL, 1)]); + var renderer = new ClanOverviewMessageRenderer(Store(clan), Resolver(), Connections(999UL), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.Null(payload.Components); + } + + // ----- Roster --------------------------------------------------------------------------- + + [Fact] + public async Task Roster_groups_members_by_role_in_rank_order() + { + var clan = Clan( + roles: [Role(3, 2, "Member"), Role(1, 0, "Leader"), Role(2, 1, "Officer")], + members: [Member(3UL, 3), Member(1UL, 1), Member(2UL, 2)]); + var renderer = new ClanRosterMessageRenderer(Store(clan), Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + Assert.Equal(3, payload.Embed.Fields.Length); + Assert.Contains("Leader", payload.Embed.Fields[0].Name, StringComparison.Ordinal); + Assert.Contains("Officer", payload.Embed.Fields[1].Name, StringComparison.Ordinal); + Assert.Contains("Member", payload.Embed.Fields[2].Name, StringComparison.Ordinal); + } + + [Fact] + public async Task Roster_puts_online_members_first_within_a_role() + { + var clan = Clan( + roles: [Role(1, 0, "Leader")], + members: + [ + Member(1UL, 1, online: false, joined: DateTimeOffset.UnixEpoch), + Member(2UL, 1, online: true, joined: DateTimeOffset.UnixEpoch.AddDays(5)), + ]); + var renderer = new ClanRosterMessageRenderer(Store(clan), Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + var body = payload.Embed.Fields[0].Value; + Assert.True( + body.IndexOf("P2", StringComparison.Ordinal) < body.IndexOf("P1", StringComparison.Ordinal), + body); + } + + [Fact] + public async Task Roster_marks_online_and_offline_members_distinctly() + { + var clan = Clan( + roles: [Role(1, 0, "Leader")], + members: [Member(1UL, 1, online: true), Member(2UL, 1, online: false)]); + var renderer = new ClanRosterMessageRenderer(Store(clan), Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + var body = payload.Embed.Fields[0].Value; + Assert.Contains("🟢 P1", body, StringComparison.Ordinal); + Assert.Contains("clan.roster.joined", body, StringComparison.Ordinal); + Assert.Contains("⚫ P2", body, StringComparison.Ordinal); + Assert.Contains("clan.roster.lastseen", body, StringComparison.Ordinal); + } + + [Fact] + public async Task Roster_shows_notes_when_present() + { + var clan = Clan( + roles: [Role(1, 0, "Leader")], + members: [Member(1UL, 1, notes: "Trial member")]); + var renderer = new ClanRosterMessageRenderer(Store(clan), Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + var body = payload.Embed.Fields[0].Value; + Assert.Contains("clan.roster.notes", body, StringComparison.Ordinal); + Assert.Contains("Trial member", body, StringComparison.Ordinal); + } + + [Fact] + public async Task Roster_falls_back_to_a_profile_link_for_an_unknown_name() + { + const string link = "[1](https://steamcommunity.com/profiles/1)"; + var clan = Clan(roles: [Role(1, 0, "Leader")], members: [Member(1UL, 1)]); + var resolver = Resolver(new Dictionary { [1UL] = link }); + var renderer = new ClanRosterMessageRenderer(Store(clan), resolver, Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + Assert.Contains(link, payload.Embed.Fields[0].Value, StringComparison.Ordinal); + } + + [Fact] + public async Task Roster_lists_members_whose_role_is_unknown_under_a_fallback_group() + { + var clan = Clan( + roles: [Role(1, 0, "Leader")], + members: [Member(1UL, 1), Member(2UL, 99)]); + var renderer = new ClanRosterMessageRenderer(Store(clan), Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + Assert.Equal(2, payload.Embed.Fields.Length); + Assert.Equal("clan.roster.unknownrole", payload.Embed.Fields[1].Name); + Assert.Contains("P2", payload.Embed.Fields[1].Value, StringComparison.Ordinal); + } + + // ----- Invites -------------------------------------------------------------------------- + + [Fact] + public async Task Invites_returns_an_empty_payload_when_there_are_none() + { + var renderer = new ClanInvitesMessageRenderer(Store(Clan()), Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Invites_lists_the_invitee_and_the_recruiter() + { + var clan = Clan(invites: [new ClanInviteSnapshot(5UL, 9UL, DateTimeOffset.UnixEpoch)]); + var resolver = Resolver(new Dictionary { [5UL] = "Newbie", [9UL] = "Grace" }); + var renderer = new ClanInvitesMessageRenderer(Store(clan), resolver, Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + Assert.Contains("clan.invites.line", payload.Embed.Description, StringComparison.Ordinal); + Assert.Contains("Newbie", payload.Embed.Description, StringComparison.Ordinal); + Assert.Contains("Grace", payload.Embed.Description, StringComparison.Ordinal); + } + + // ----- Helpers -------------------------------------------------------------------------- + + private static string FieldValue(IEmbed embed, string name) => + embed.Fields.Single(f => string.Equals(f.Name, name, StringComparison.Ordinal)).Value; + + /// + /// A localizer that echoes the key it was asked for, plus any format arguments, so assertions + /// match on stable keys and on the data actually passed rather than on English copy. + /// + private static ILocalizer Localizer() + { + var localizer = Substitute.For(); + localizer.Get(Arg.Any(), Arg.Any()).Returns(ci => ci.ArgAt(0)); + localizer.Get(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => $"{ci.ArgAt(0)} {string.Join(' ', ci.ArgAt(2))}"); + return localizer; + } + + private static IClanStore Store(ClanSnapshot clan) + { + var store = Substitute.For(); + store.GetAsync(Guild, Server, Arg.Any()).Returns(Task.FromResult(clan)); + return store; + } + + private static IConnectionStore Connections(ulong steamId) + { + var connections = Substitute.For(); + connections.GetActiveCredentialAsync(Guild, Server, Arg.Any()) + .Returns(Task.FromResult(new PlayerCredential { SteamId = steamId })); + return connections; + } + + /// Resolves every requested id, using the supplied names where given and "P{id}" otherwise. + /// Names to use for specific ids. + /// The substituted resolver. + private static IClanNameResolver Resolver(Dictionary? known = null) + { + var resolver = Substitute.For(); + resolver.ResolveAsync( + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .Returns(ci => Task.FromResult>( + ci.ArgAt>(2).ToDictionary( + id => id, + id => known is not null && known.TryGetValue(id, out var name) + ? name + : $"P{id.ToString(CultureInfo.InvariantCulture)}"))); + return resolver; + } + + private static ClanRoleSnapshot Role(int roleId, int rank, string name, bool canSetMotd = false) => + new(roleId, rank, name, canSetMotd, false, false, false, false, false, false, false, false); + + private static ClanMemberSnapshot Member( + ulong steamId, + int roleId, + bool online = true, + DateTimeOffset? joined = null, + string? notes = null) => + new(steamId, roleId, joined ?? DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, notes, online); + + private static ClanSnapshot Clan( + string name = "Wolves", + string? motd = null, + ulong? motdAuthor = null, + int? color = null, + int? maxMemberCount = null, + long? score = null, + IReadOnlyList? roles = null, + IReadOnlyList? members = null, + IReadOnlyList? invites = null) => new( + 1, + name, + DateTimeOffset.UnixEpoch, + 1UL, + motd, + motd is null ? null : DateTimeOffset.UnixEpoch, + motdAuthor, + null, + color, + maxMemberCount, + score, + roles ?? [Role(1, 0, "Leader"), Role(2, 1, "Member")], + members ?? [Member(1UL, 1)], + invites ?? []); +} diff --git a/tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs b/tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs new file mode 100644 index 00000000..ca7a949c --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs @@ -0,0 +1,73 @@ +using NSubstitute; +using RustPlusBot.Features.Clans.Names; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.Tests.Names; + +public sealed class ClanNameResolverTests +{ + private const ulong Guild = 42UL; + + private static readonly Guid Server = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + [Fact] + public async Task Returns_cached_names_for_known_ids() + { + var store = Substitute.For(); + store.GetNamesAsync(Guild, Server, Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult>( + new Dictionary { [1UL] = "Ada", [2UL] = "Grace" })); + + var resolved = await new ClanNameResolver(store) + .ResolveAsync(Guild, Server, [1UL, 2UL], CancellationToken.None); + + Assert.Equal("Ada", resolved[1UL]); + Assert.Equal("Grace", resolved[2UL]); + } + + [Fact] + public async Task Falls_back_to_a_steam_profile_link_for_unknown_ids() + { + var store = Substitute.For(); + store.GetNamesAsync(Guild, Server, Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult>(new Dictionary())); + + var resolved = await new ClanNameResolver(store) + .ResolveAsync(Guild, Server, [76561198000000000UL], CancellationToken.None); + + Assert.Equal( + "[76561198000000000](https://steamcommunity.com/profiles/76561198000000000)", + resolved[76561198000000000UL]); + } + + [Fact] + public async Task Covers_every_requested_id() + { + var store = Substitute.For(); + store.GetNamesAsync(Guild, Server, Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult>( + new Dictionary { [1UL] = "Ada", [3UL] = " " })); + + var resolved = await new ClanNameResolver(store) + .ResolveAsync(Guild, Server, [1UL, 2UL, 3UL], CancellationToken.None); + + Assert.Equal(3, resolved.Count); + Assert.True(resolved.ContainsKey(1UL)); + Assert.True(resolved.ContainsKey(2UL)); + + // A blank cached name is no better than no name at all. + Assert.Equal("[3](https://steamcommunity.com/profiles/3)", resolved[3UL]); + } + + [Fact] + public async Task Returns_an_empty_map_for_an_empty_request_without_querying_the_store() + { + var store = Substitute.For(); + + var resolved = await new ClanNameResolver(store).ResolveAsync(Guild, Server, [], CancellationToken.None); + + Assert.Empty(resolved); + await store.DidNotReceiveWithAnyArgs() + .GetNamesAsync(default, Guid.Empty, default!, default); + } +} From 3d098681e78a42da48d03560294d0b53ff4947cb Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 01:32:07 +0200 Subject: [PATCH 24/39] Warn that Task 12's key grep misses interpolated keys The clan.roster.perm.* keys are composed from permission flag names and never appear as quoted literals, so the grep-based collection step would miss them. A missed key fails no test - it renders as raw key text in a Discord embed. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-21-clan-support.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-21-clan-support.md b/docs/superpowers/plans/2026-07-21-clan-support.md index c344de47..7c5e5dda 100644 --- a/docs/superpowers/plans/2026-07-21-clan-support.md +++ b/docs/superpowers/plans/2026-07-21-clan-support.md @@ -3283,11 +3283,22 @@ EOF - [ ] **Step 1: Collect the exact key list** ```bash -grep -rhoE '"(channel\.clan[a-z.]*|clan\.[a-z.]+|server\.clan[a-z.]*)"' src/RustPlusBot.Features.Clans \ +grep -rhoE '"(channel\.clan[a-z.]*|clan\.[a-z.]+|server\.clan[a-z.]*)"' \ + src/RustPlusBot.Features.Clans src/RustPlusBot.Features.Workspace \ | tr -d '"' | sort -u ``` -Every key that command prints MUST exist in both resx files. Work from that list, not from memory. +Every key that command prints MUST exist in both resx files. + +**That grep is necessary but NOT sufficient.** Some keys are built by string interpolation and +never appear as a quoted literal — notably the nine `clan.roster.perm.*` keys, which the roster +renderer composes from each `ClanRoleSnapshot` permission flag name. Grep for `perm.` and for +`$"` inside `src/RustPlusBot.Features.Clans` and enumerate those by hand. A missed interpolated +key does not fail any test; it silently renders as the raw key text inside a Discord embed field +name, which is exactly the failure this step exists to prevent. + +Cross-check your final list against `Task 9`'s report at `.superpowers/sdd/task-9-report.md`, +which enumerates the interpolated key list explicitly. - [ ] **Step 2: Add every key to `Strings.resx`** From 1fae657b55bf8a83e72515c3266ca7d6977429e6 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 01:47:18 +0200 Subject: [PATCH 25/39] Pin clan #claninfo renderer contracts that mutation testing found unpinned Close five test-coverage gaps from Task 9 review: the three renderers' empty-payload paths, the Set MOTD button's credential/role hide-branches, and the roster field-truncation arithmetic all had implementations that were correct but not pinned by tests, so six reviewer mutations to src/ left all 43 existing tests green. Also decorrelate the roster ordering test's Rank/RoleId fixture, pin one resolver call per render, and add a cross-assembly test asserting the renderer key literals still match WorkspaceMessageKeys so a future rename can't silently unhook a renderer. No src/ changes; each new assertion was verified to fail against the corresponding mutation before being restored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Messages/ClanRendererTests.cs | 128 +++++++++++++++++- .../Messages/ClanMessageKeyParityTests.cs | 20 +++ ...ustPlusBot.Features.Workspace.Tests.csproj | 3 + 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Messages/ClanMessageKeyParityTests.cs diff --git a/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs b/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs index c46a2e8c..bd16c2a2 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs @@ -151,15 +151,79 @@ public async Task Overview_hides_the_set_motd_button_when_the_active_player_is_n Assert.Null(payload.Components); } + [Fact] + public async Task Overview_hides_the_set_motd_button_when_there_is_no_active_credential() + { + // The granted-role member's SteamId (1UL) matches the clan's default Creator (see Clan()), + // so a mutation that falls back to the creator when there is no credential would + // incorrectly resolve a member and show the button instead of hiding it. + var clan = Clan( + roles: [Role(1, 0, "Leader", canSetMotd: true)], + members: [Member(1UL, 1)]); + var connections = Substitute.For(); + connections.GetActiveCredentialAsync(Guild, Server, Arg.Any()) + .Returns(Task.FromResult(null)); + var renderer = new ClanOverviewMessageRenderer(Store(clan), Resolver(), connections, Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.Null(payload.Components); + } + + [Fact] + public async Task Overview_hides_the_set_motd_button_when_the_role_is_unknown() + { + // The active player is a clan member, but their RoleId (99) matches none of the clan's roles. + var clan = Clan( + roles: [Role(1, 0, "Leader", canSetMotd: true)], + members: [Member(7UL, 99)]); + var renderer = new ClanOverviewMessageRenderer(Store(clan), Resolver(), Connections(7UL), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.Null(payload.Components); + } + // ----- Roster --------------------------------------------------------------------------- + [Fact] + public async Task Roster_returns_an_empty_payload_without_a_server_id() + { + var store = Substitute.For(); + var renderer = new ClanRosterMessageRenderer(store, Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(new MessageRenderContext(Guild, null, "en"), CancellationToken.None); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Roster_returns_an_empty_payload_when_no_clan_is_stored() + { + var store = Substitute.For(); + store.GetAsync(Guild, Server, Arg.Any()).Returns(Task.FromResult(null)); + var renderer = new ClanRosterMessageRenderer(store, Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + [Fact] public async Task Roster_groups_members_by_role_in_rank_order() { + // RoleId intentionally does not correlate with Rank (Leader=rank0/id9, Officer=rank1/id2, + // Member=rank2/id5), so a mutation that orders by RoleId alone (dropping the Rank key) + // would visibly reorder the fields below. var clan = Clan( - roles: [Role(3, 2, "Member"), Role(1, 0, "Leader"), Role(2, 1, "Officer")], - members: [Member(3UL, 3), Member(1UL, 1), Member(2UL, 2)]); - var renderer = new ClanRosterMessageRenderer(Store(clan), Resolver(), Localizer()); + roles: [Role(9, 0, "Leader"), Role(2, 1, "Officer"), Role(5, 2, "Member")], + members: [Member(1UL, 9), Member(2UL, 2), Member(3UL, 5)]); + var resolver = Resolver(); + var renderer = new ClanRosterMessageRenderer(Store(clan), resolver, Localizer()); var payload = await renderer.RenderAsync(Context, CancellationToken.None); @@ -168,6 +232,37 @@ public async Task Roster_groups_members_by_role_in_rank_order() Assert.Contains("Leader", payload.Embed.Fields[0].Name, StringComparison.Ordinal); Assert.Contains("Officer", payload.Embed.Fields[1].Name, StringComparison.Ordinal); Assert.Contains("Member", payload.Embed.Fields[2].Name, StringComparison.Ordinal); + + // One batched name-resolution call for the whole roster, not one per member. + await resolver.Received(1).ResolveAsync( + Guild, Server, Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task Roster_truncates_a_role_body_that_would_exceed_the_field_value_limit() + { + var members = Enumerable.Range(1, 60) + .Select(i => Member((ulong)i, 1, joined: DateTimeOffset.UnixEpoch)) + .ToList(); + var clan = Clan(roles: [Role(1, 0, "Leader")], members: members); + var renderer = new ClanRosterMessageRenderer(Store(clan), Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + var value = payload.Embed.Fields[0].Value; + Assert.True(value.Length <= 1024, $"Field value length {value.Length} exceeds Discord's 1024 cap."); + + var lines = value.Split('\n'); + var noticeLine = lines[^1]; + Assert.Contains("clan.roster.truncated", noticeLine, StringComparison.Ordinal); + + // Compute the expected omitted count from what actually rendered rather than hard-coding + // it, so this stays valid if line formatting changes. + var renderedMemberLines = lines.Length - 1; + var expectedOmitted = members.Count - renderedMemberLines; + var omittedToken = noticeLine.Split(' ')[^1]; + Assert.Equal(expectedOmitted.ToString(CultureInfo.InvariantCulture), omittedToken); } [Fact] @@ -257,6 +352,33 @@ public async Task Roster_lists_members_whose_role_is_unknown_under_a_fallback_gr // ----- Invites -------------------------------------------------------------------------- + [Fact] + public async Task Invites_returns_an_empty_payload_without_a_server_id() + { + var store = Substitute.For(); + var renderer = new ClanInvitesMessageRenderer(store, Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(new MessageRenderContext(Guild, null, "en"), CancellationToken.None); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Invites_returns_an_empty_payload_when_no_clan_is_stored() + { + var store = Substitute.For(); + store.GetAsync(Guild, Server, Arg.Any()).Returns(Task.FromResult(null)); + var renderer = new ClanInvitesMessageRenderer(store, Resolver(), Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + [Fact] public async Task Invites_returns_an_empty_payload_when_there_are_none() { diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/ClanMessageKeyParityTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ClanMessageKeyParityTests.cs new file mode 100644 index 00000000..0daaab86 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ClanMessageKeyParityTests.cs @@ -0,0 +1,20 @@ +using RustPlusBot.Features.Clans.Messages; + +namespace RustPlusBot.Features.Workspace.Tests.Messages; + +/// +/// Pins that the clan renderers' key literals (declared in Features.Clans, which cannot see +/// Features.Workspace's internal ) still match the keys the +/// reconciler looks renderers up by. The reconciler compares with StringComparer.Ordinal, +/// so a silent rename on either side would unhook a renderer without any other test catching it. +/// +public sealed class ClanMessageKeyParityTests +{ + [Fact] + public void Clan_renderer_keys_match_the_workspace_message_key_constants() + { + Assert.Equal(WorkspaceMessageKeys.ClanOverview, ClanOverviewMessageRenderer.Key); + Assert.Equal(WorkspaceMessageKeys.ClanRoster, ClanRosterMessageRenderer.Key); + Assert.Equal(WorkspaceMessageKeys.ClanInvites, ClanInvitesMessageRenderer.Key); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj b/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj index d6bb95c8..6dca572a 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj +++ b/tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj @@ -16,6 +16,9 @@ + + From 227b57ab7aad7431fb4d7061354b14b0b5a461d2 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 11:20:55 +0200 Subject: [PATCH 26/39] Add the clan MOTD button and modal Route the Set MOTD interaction through ClanMotdWriter, which re-checks the acting player's in-game clan permission rather than trusting the button's presence, then writes through a new IRustServerQuery method. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Connections/IRustServerQuery.cs | 8 + .../Modules/ClanMotdModal.cs | 16 ++ .../Modules/ClanMotdModule.cs | 88 ++++++++++ .../Writing/ClanMotdWriter.cs | 49 ++++++ .../Writing/IClanMotdWriter.cs | 31 ++++ .../Supervisor/ConnectionSupervisor.cs | 17 ++ .../Writing/ClanMotdWriterTests.cs | 159 ++++++++++++++++++ 7 files changed, 368 insertions(+) create mode 100644 src/RustPlusBot.Features.Clans/Modules/ClanMotdModal.cs create mode 100644 src/RustPlusBot.Features.Clans/Modules/ClanMotdModule.cs create mode 100644 src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs create mode 100644 src/RustPlusBot.Features.Clans/Writing/IClanMotdWriter.cs create mode 100644 tests/RustPlusBot.Features.Clans.Tests/Writing/ClanMotdWriterTests.cs diff --git a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs index c5e2d603..27fa751e 100644 --- a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs +++ b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs @@ -124,4 +124,12 @@ Task StrobeSmartSwitchAsync(ulong guildId, int timeoutMs, bool value, CancellationToken cancellationToken); + + /// Sets the clan message of the day on a live socket. + /// The owning guild snowflake. + /// The target server id. + /// The new message of the day. + /// A cancellation token. + /// True on success; false when not connected or the write failed. + Task SetClanMotdAsync(ulong guildId, Guid serverId, string motd, CancellationToken cancellationToken); } diff --git a/src/RustPlusBot.Features.Clans/Modules/ClanMotdModal.cs b/src/RustPlusBot.Features.Clans/Modules/ClanMotdModal.cs new file mode 100644 index 00000000..f69b268d --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Modules/ClanMotdModal.cs @@ -0,0 +1,16 @@ +using Discord; +using Discord.Interactions; + +namespace RustPlusBot.Features.Clans.Modules; + +/// The modal that collects a new clan MOTD. Handled by . +public sealed class ClanMotdModal : IModal +{ + /// The new message of the day. + [InputLabel("Message of the day")] + [ModalTextInput(ClanComponentIds.MotdInputId, TextInputStyle.Paragraph, maxLength: 1024)] + public string Motd { get; set; } = string.Empty; + + /// + public string Title => "Set clan MOTD"; +} diff --git a/src/RustPlusBot.Features.Clans/Modules/ClanMotdModule.cs b/src/RustPlusBot.Features.Clans/Modules/ClanMotdModule.cs new file mode 100644 index 00000000..16849bfe --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Modules/ClanMotdModule.cs @@ -0,0 +1,88 @@ +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Clans.Writing; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Clans.Modules; + +/// +/// Thin handler for the clan Set MOTD button and modal. Any guild member may open the modal; the +/// in-game clan permission is enforced server-side by , not here. +/// +/// Creates a short-lived DI scope per interaction. +/// Resolves localized reply strings. +public sealed class ClanMotdModule(IServiceScopeFactory scopeFactory, ILocalizer localizer) + : InteractionModuleBase +{ + private const string InvalidControlMessage = "That control wasn't valid."; + + /// Opens the Set MOTD modal, carrying the target server id in the modal custom id. + /// The server id (from 's custom id). + [ComponentInteraction(ClanComponentIds.SetMotdButtonPrefix + "*")] + public async Task OpenAsync(string tail) + { + if (Context.Guild is null) + { + await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false); + return; + } + + await RespondWithModalAsync(ClanComponentIds.SetMotdModalPrefix + tail) + .ConfigureAwait(false); + } + + /// Applies the new MOTD via , then refreshes the overview embed. + /// The server id (from 's custom id). + /// The submitted MOTD modal. + [ModalInteraction(ClanComponentIds.SetMotdModalPrefix + "*")] + public async Task SubmitAsync(string tail, ClanMotdModal modal) + { + ArgumentNullException.ThrowIfNull(modal); + if (!Guid.TryParse(tail, out var serverId) || Context.Guild is null) + { + await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var guildId = Context.Guild.Id; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var culture = await scope.ServiceProvider.GetRequiredService() + .GetCultureAsync(guildId, CancellationToken.None).ConfigureAwait(false); + + var credential = await scope.ServiceProvider.GetRequiredService() + .GetActiveCredentialAsync(guildId, serverId, CancellationToken.None).ConfigureAwait(false); + if (credential is null) + { + await FollowupAsync(localizer.Get("clan.motd.notpermitted", culture), ephemeral: true) + .ConfigureAwait(false); + return; + } + + var writer = scope.ServiceProvider.GetRequiredService(); + var result = await writer + .SetAsync(guildId, serverId, credential.SteamId, modal.Motd, CancellationToken.None) + .ConfigureAwait(false); + + if (result == ClanMotdWriteResult.Ok) + { + var refresher = scope.ServiceProvider.GetRequiredService(); + await refresher.RefreshAsync(guildId, serverId, CancellationToken.None).ConfigureAwait(false); + } + + await FollowupAsync(Describe(result, culture), ephemeral: true).ConfigureAwait(false); + } + } + + private string Describe(ClanMotdWriteResult result, string culture) => result switch + { + ClanMotdWriteResult.Ok => localizer.Get("clan.motd.ok", culture), + ClanMotdWriteResult.NotPermitted => localizer.Get("clan.motd.notpermitted", culture), + _ => localizer.Get("clan.motd.failed", culture), + }; +} diff --git a/src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs b/src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs new file mode 100644 index 00000000..303e2548 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs @@ -0,0 +1,49 @@ +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.Writing; + +/// +/// Applies a clan MOTD change, enforcing the acting player's clan permission before touching the +/// socket. The permission check is duplicated here rather than trusted from the button's presence: +/// component payloads can be forged, and roles can change between render and click. +/// +/// Supplies the stored clan snapshot for the permission check. +/// Performs the write on the live socket. +internal sealed class ClanMotdWriter(IClanStore store, IRustServerQuery query) : IClanMotdWriter +{ + /// + public async Task SetAsync(ulong guildId, + Guid serverId, + ulong actorSteamId, + string motd, + CancellationToken cancellationToken) + { + var trimmed = motd?.Trim() ?? string.Empty; + if (trimmed.Length == 0) + { + return ClanMotdWriteResult.Failed; + } + + var clan = await store.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (clan is null) + { + return ClanMotdWriteResult.NotPermitted; + } + + var member = clan.Members.FirstOrDefault(m => m.SteamId == actorSteamId); + if (member is null) + { + return ClanMotdWriteResult.NotPermitted; + } + + var role = clan.Roles.FirstOrDefault(r => r.RoleId == member.RoleId); + if (role?.CanSetMotd != true) + { + return ClanMotdWriteResult.NotPermitted; + } + + var ok = await query.SetClanMotdAsync(guildId, serverId, trimmed, cancellationToken).ConfigureAwait(false); + return ok ? ClanMotdWriteResult.Ok : ClanMotdWriteResult.Failed; + } +} diff --git a/src/RustPlusBot.Features.Clans/Writing/IClanMotdWriter.cs b/src/RustPlusBot.Features.Clans/Writing/IClanMotdWriter.cs new file mode 100644 index 00000000..d518d0f8 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Writing/IClanMotdWriter.cs @@ -0,0 +1,31 @@ +namespace RustPlusBot.Features.Clans.Writing; + +/// The outcome of a clan MOTD write. +internal enum ClanMotdWriteResult +{ + /// The MOTD was set. + Ok = 0, + + /// The acting player's clan role does not allow setting the MOTD. + NotPermitted = 1, + + /// The write did not succeed: no live socket, a rejected write, or blank input. + Failed = 2, +} + +/// Applies a clan MOTD change on behalf of an acting player, enforcing their in-game clan permission. +internal interface IClanMotdWriter +{ + /// Sets the clan's message of the day, re-checking the acting player's permission first. + /// The owning guild snowflake. + /// The target server id. + /// Steam64 id of the player performing the change. + /// The new message of the day. + /// A cancellation token. + /// The outcome of the write. + Task SetAsync(ulong guildId, + Guid serverId, + ulong actorSteamId, + string motd, + CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 7cf29759..792e1416 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -363,6 +363,23 @@ public async Task StrobeSmartSwitchAsync( .ConfigureAwait(false); } + /// + public async Task SetClanMotdAsync( + ulong guildId, + Guid serverId, + string motd, + CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return false; + } + + return await live.Connection + .SetClanMotdAsync(motd, _options.HeartbeatTimeout, cancellationToken) + .ConfigureAwait(false); + } + /// public async Task SendAsync( ChatChannelKind kind, diff --git a/tests/RustPlusBot.Features.Clans.Tests/Writing/ClanMotdWriterTests.cs b/tests/RustPlusBot.Features.Clans.Tests/Writing/ClanMotdWriterTests.cs new file mode 100644 index 00000000..9842921b --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/Writing/ClanMotdWriterTests.cs @@ -0,0 +1,159 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Clans.Writing; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.Tests.Writing; + +public sealed class ClanMotdWriterTests +{ + private const ulong Guild = 42UL; + private const ulong Actor = 7UL; + + private static readonly Guid Server = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + [Fact] + public async Task Rejects_a_writer_with_no_stored_clan() + { + var store = Substitute.For(); + store.GetAsync(Guild, Server, Arg.Any()).Returns((ClanSnapshot?)null); + var query = Substitute.For(); + var writer = new ClanMotdWriter(store, query); + + var result = await writer.SetAsync(Guild, Server, Actor, "New MOTD", CancellationToken.None); + + Assert.Equal(ClanMotdWriteResult.NotPermitted, result); + await query.DidNotReceive() + .SetClanMotdAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Rejects_an_actor_who_is_not_a_clan_member() + { + var clan = Clan(members: [Member(1UL, 1)]); + var store = Store(clan); + var query = Substitute.For(); + var writer = new ClanMotdWriter(store, query); + + var result = await writer.SetAsync(Guild, Server, Actor, "New MOTD", CancellationToken.None); + + Assert.Equal(ClanMotdWriteResult.NotPermitted, result); + await query.DidNotReceive() + .SetClanMotdAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Rejects_an_actor_whose_role_cannot_set_the_motd() + { + var clan = Clan( + roles: [Role(1, 0, "Member", canSetMotd: false)], + members: [Member(Actor, 1)]); + var store = Store(clan); + var query = Substitute.For(); + var writer = new ClanMotdWriter(store, query); + + var result = await writer.SetAsync(Guild, Server, Actor, "New MOTD", CancellationToken.None); + + Assert.Equal(ClanMotdWriteResult.NotPermitted, result); + await query.DidNotReceive() + .SetClanMotdAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Rejects_an_actor_whose_role_id_is_unknown() + { + var clan = Clan( + roles: [Role(1, 0, "Leader", canSetMotd: true)], + members: [Member(Actor, 99)]); + var store = Store(clan); + var query = Substitute.For(); + var writer = new ClanMotdWriter(store, query); + + var result = await writer.SetAsync(Guild, Server, Actor, "New MOTD", CancellationToken.None); + + Assert.Equal(ClanMotdWriteResult.NotPermitted, result); + await query.DidNotReceive() + .SetClanMotdAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Writes_the_motd_when_the_actor_is_permitted() + { + var clan = Clan( + roles: [Role(1, 0, "Leader", canSetMotd: true)], + members: [Member(Actor, 1)]); + var store = Store(clan); + var query = Substitute.For(); + query.SetClanMotdAsync(Guild, Server, "New MOTD", Arg.Any()).Returns(true); + var writer = new ClanMotdWriter(store, query); + + var result = await writer.SetAsync(Guild, Server, Actor, "New MOTD", CancellationToken.None); + + Assert.Equal(ClanMotdWriteResult.Ok, result); + await query.Received(1).SetClanMotdAsync(Guild, Server, "New MOTD", Arg.Any()); + } + + [Fact] + public async Task Reports_Failed_when_the_socket_write_fails() + { + var clan = Clan( + roles: [Role(1, 0, "Leader", canSetMotd: true)], + members: [Member(Actor, 1)]); + var store = Store(clan); + var query = Substitute.For(); + query.SetClanMotdAsync(Guild, Server, "New MOTD", Arg.Any()).Returns(false); + var writer = new ClanMotdWriter(store, query); + + var result = await writer.SetAsync(Guild, Server, Actor, "New MOTD", CancellationToken.None); + + Assert.Equal(ClanMotdWriteResult.Failed, result); + } + + [Fact] + public async Task Trims_and_rejects_a_blank_motd() + { + var clan = Clan( + roles: [Role(1, 0, "Leader", canSetMotd: true)], + members: [Member(Actor, 1)]); + var store = Store(clan); + var query = Substitute.For(); + var writer = new ClanMotdWriter(store, query); + + var result = await writer.SetAsync(Guild, Server, Actor, " ", CancellationToken.None); + + Assert.Equal(ClanMotdWriteResult.Failed, result); + await query.DidNotReceive() + .SetClanMotdAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + private static IClanStore Store(ClanSnapshot clan) + { + var store = Substitute.For(); + store.GetAsync(Guild, Server, Arg.Any()).Returns(clan); + return store; + } + + private static ClanRoleSnapshot Role(int roleId, int rank, string name, bool canSetMotd = false) => + new(roleId, rank, name, canSetMotd, false, false, false, false, false, false, false, false); + + private static ClanMemberSnapshot Member(ulong steamId, int roleId) => + new(steamId, roleId, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, null, true); + + private static ClanSnapshot Clan( + IReadOnlyList? roles = null, + IReadOnlyList? members = null) => new( + 1, + "Wolves", + DateTimeOffset.UnixEpoch, + 1UL, + null, + null, + null, + null, + null, + null, + null, + roles ?? [Role(1, 0, "Leader")], + members ?? [Member(1UL, 1)], + []); +} From 04aba207283320470b47a845adc1cb2370573206 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 11:38:50 +0200 Subject: [PATCH 27/39] Apply clan state, post the change feed, and wire the module Persist each snapshot, drive the workspace clan capability from its presence, and post diffed changes into #claninfo. Reconcile runs only on a none-to-clan or clan-to-none transition, and an Unavailable probe changes nothing at all. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ClansServiceCollectionExtensions.cs | 51 ++++ .../Hosting/ClansHostedService.cs | 78 ++++++ .../Messages/ClanChangeRenderer.cs | 81 ++++++ .../Posting/DiscordClanFeedPoster.cs | 50 ++++ .../Posting/IClanFeedPoster.cs | 12 + .../State/ClanCapabilityProvider.cs | 67 +++++ .../State/ClanStateService.cs | 243 ++++++++++++++++++ .../Locating/ClanInfoChannelLocator.cs | 10 + .../Locating/IClanInfoChannelLocator.cs | 15 ++ .../RustPlusBot.Features.Workspace.csproj | 2 + .../WorkspaceServiceCollectionExtensions.cs | 1 + .../ClansRegistrationTests.cs | 107 ++++++++ .../State/ClanStateServiceTests.cs | 228 ++++++++++++++++ 13 files changed, 945 insertions(+) create mode 100644 src/RustPlusBot.Features.Clans/ClansServiceCollectionExtensions.cs create mode 100644 src/RustPlusBot.Features.Clans/Hosting/ClansHostedService.cs create mode 100644 src/RustPlusBot.Features.Clans/Messages/ClanChangeRenderer.cs create mode 100644 src/RustPlusBot.Features.Clans/Posting/DiscordClanFeedPoster.cs create mode 100644 src/RustPlusBot.Features.Clans/Posting/IClanFeedPoster.cs create mode 100644 src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs create mode 100644 src/RustPlusBot.Features.Clans/State/ClanStateService.cs create mode 100644 src/RustPlusBot.Features.Workspace/Locating/ClanInfoChannelLocator.cs create mode 100644 src/RustPlusBot.Features.Workspace/Locating/IClanInfoChannelLocator.cs create mode 100644 tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs create mode 100644 tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs diff --git a/src/RustPlusBot.Features.Clans/ClansServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Clans/ClansServiceCollectionExtensions.cs new file mode 100644 index 00000000..a2d3ec45 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/ClansServiceCollectionExtensions.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Discord; +using RustPlusBot.Features.Clans.Hosting; +using RustPlusBot.Features.Clans.Messages; +using RustPlusBot.Features.Clans.Names; +using RustPlusBot.Features.Clans.Posting; +using RustPlusBot.Features.Clans.State; +using RustPlusBot.Features.Clans.Writing; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Clans; + +/// DI registration for the clan feature (state, #claninfo embeds and feed, Set MOTD). +public static class ClansServiceCollectionExtensions +{ + /// + /// Registers the clan state service, the workspace clan capability, the #claninfo feed poster + /// and renderers, the MOTD writer, and the hosted consumer loop. + /// + /// The service collection to add to. + /// The same service collection, for chaining. + public static IServiceCollection AddClans(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddRustPlusBotLocalization(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // One instance, two roles: the registry indexes capability providers by name (a duplicate + // "clan" registration would throw at construction), and the state service invalidates the + // very same cache before a transition reconcile. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + // Scoped: these reach the DbContext through IClanStore. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(new InteractionModuleAssembly(typeof(ClansServiceCollectionExtensions).Assembly)); + services.AddHostedService(); + + return services; + } +} diff --git a/src/RustPlusBot.Features.Clans/Hosting/ClansHostedService.cs b/src/RustPlusBot.Features.Clans/Hosting/ClansHostedService.cs new file mode 100644 index 00000000..0c198eaa --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Hosting/ClansHostedService.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Clans.State; + +namespace RustPlusBot.Features.Clans.Hosting; + +/// +/// Runs the single clan state consumer loop. Clan chat is not handled here at all: Features.Chat +/// owns both chat bridges, and nothing is ever read back out of #claninfo. +/// +/// The in-process event bus. +/// Applies each observed clan state change. +/// The logger. +internal sealed partial class ClansHostedService( + IEventBus eventBus, + ClanStateService stateService, + ILogger logger) : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private Task? _stateLoop; + + /// + public void Dispose() => _cts.Dispose(); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _stateLoop = Task.Run(() => ConsumeClanStateAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync().ConfigureAwait(false); + if (_stateLoop is null) + { + return; + } + + try + { +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — this is our own loop task, joined on stop. + await _stateLoop.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + + private async Task ConsumeClanStateAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await stateService.ApplyAsync(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 + { + LogStateLoopFaulted(logger, ex); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Clan state loop faulted.")] + private static partial void LogStateLoopFaulted(ILogger logger, Exception exception); +} diff --git a/src/RustPlusBot.Features.Clans/Messages/ClanChangeRenderer.cs b/src/RustPlusBot.Features.Clans/Messages/ClanChangeRenderer.cs new file mode 100644 index 00000000..2fdfb3a0 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Messages/ClanChangeRenderer.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using RustPlusBot.Features.Clans.State; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Clans.Messages; + +/// +/// Turns one detected into a localized #claninfo feed line. Pure: no I/O +/// and no state, so ordering and throttling stay the caller's concern. +/// +/// String resolution. +internal sealed class ClanChangeRenderer(ILocalizer localizer) +{ + /// Renders one change into a feed line, or null when it should not be posted. + /// The change to render. + /// Resolved display names covering every id referenced by the change. + /// The guild culture. + /// The feed line, or null. + public string? Render(ClanChange change, IReadOnlyDictionary names, string culture) + { + ArgumentNullException.ThrowIfNull(change); + ArgumentNullException.ThrowIfNull(names); + + return change.Kind switch + { + ClanChangeKind.Dissolved => localizer.Get("clan.event.dissolved", culture, change.Text ?? string.Empty), + ClanChangeKind.Renamed => localizer.Get("clan.event.renamed", culture, change.Text ?? string.Empty), + ClanChangeKind.MotdChanged => RenderMotd(change, names, culture), + ClanChangeKind.MemberJoined => + localizer.Get("clan.event.joined", culture, Name(names, change.SteamId)), + ClanChangeKind.MemberLeft => + localizer.Get("clan.event.left", culture, Name(names, change.SteamId)), + ClanChangeKind.MemberPromoted => + localizer.Get("clan.event.promoted", culture, Name(names, change.SteamId), + change.RoleName ?? string.Empty), + ClanChangeKind.MemberDemoted => + localizer.Get("clan.event.demoted", culture, Name(names, change.SteamId), + change.RoleName ?? string.Empty), + ClanChangeKind.InviteSent => + localizer.Get("clan.event.invited", culture, Name(names, change.SteamId), + Name(names, change.ActorSteamId)), + ClanChangeKind.InviteAccepted => + localizer.Get("clan.event.inviteaccepted", culture, Name(names, change.SteamId)), + ClanChangeKind.InviteRevoked => + localizer.Get("clan.event.inviterevoked", culture, Name(names, change.SteamId)), + ClanChangeKind.LogoChanged => localizer.Get("clan.event.logo", culture), + ClanChangeKind.ColorChanged => localizer.Get("clan.event.color", culture), + ClanChangeKind.ScoreChanged => localizer.Get("clan.event.score", culture, + (change.Score ?? 0L).ToString(CultureInfo.InvariantCulture)), + _ => null, + }; + } + + /// + /// Resolves a display name, falling back to the bare Steam id so a rendering never throws on an + /// id the resolver did not cover. + /// + /// The resolved names. + /// The id to name, or null. + /// The display name, the bare id, or an empty string when there is no id. + private static string Name(IReadOnlyDictionary names, ulong? steamId) + { + if (steamId is not { } id) + { + return string.Empty; + } + + return names.TryGetValue(id, out var name) ? name : id.ToString(CultureInfo.InvariantCulture); + } + + private string RenderMotd(ClanChange change, IReadOnlyDictionary names, string culture) + { + // A cleared MOTD reads as its own event; "set the MOTD to (nothing)" would be nonsense. + if (string.IsNullOrWhiteSpace(change.Text)) + { + return localizer.Get("clan.event.motdcleared", culture); + } + + return localizer.Get("clan.event.motd", culture, Name(names, change.ActorSteamId), change.Text); + } +} diff --git a/src/RustPlusBot.Features.Clans/Posting/DiscordClanFeedPoster.cs b/src/RustPlusBot.Features.Clans/Posting/DiscordClanFeedPoster.cs new file mode 100644 index 00000000..f10199cf --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Posting/DiscordClanFeedPoster.cs @@ -0,0 +1,50 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; + +namespace RustPlusBot.Features.Clans.Posting; + +/// +/// Real . Feed lines are plain bot messages rather than webhook +/// impersonation: this is the bot narrating clan events, not a player speaking. Untested +/// integration shim. +/// +/// The Discord socket client. +/// The logger. +internal sealed partial class DiscordClanFeedPoster( + DiscordSocketClient client, + ILogger logger) : IClanFeedPoster +{ + /// + public async Task PostAsync(ulong channelId, string text, 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(text, options: options, allowedMentions: AllowedMentions.None) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Shutdown — let the loop unwind. + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the clan state loop. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPostFailed(logger, ex, channelId); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Posting a clan feed line to channel {ChannelId} failed.")] + private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId); +} diff --git a/src/RustPlusBot.Features.Clans/Posting/IClanFeedPoster.cs b/src/RustPlusBot.Features.Clans/Posting/IClanFeedPoster.cs new file mode 100644 index 00000000..c84f9591 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Posting/IClanFeedPoster.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Clans.Posting; + +/// Posts a plain feed line into the #claninfo channel. +internal interface IClanFeedPoster +{ + /// Posts one feed line. + /// The #claninfo channel snowflake. + /// The already-localized line to post. + /// A cancellation token. + /// A task that completes when the post has been attempted. + Task PostAsync(ulong channelId, string text, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs b/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs new file mode 100644 index 00000000..3477dfdf --- /dev/null +++ b/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs @@ -0,0 +1,67 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Workspace; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.State; + +/// +/// Reports the "clan" capability as available exactly while a clan snapshot is stored for the +/// server, which is what gates the two clan channels. +/// +/// Opens scopes for the scoped clan store. +/// Drives the short-lived answer cache. +internal sealed class ClanCapabilityProvider(IServiceScopeFactory scopeFactory, IClock clock) + : IWorkspaceCapabilityProvider +{ + /// + /// How long an answer is reused. Two gated specs mean two probes per server per reconcile, and + /// the heal path reconciles every server on a timer; this is kept well under the reconcile + /// interval so a clan transition is still picked up promptly. + /// + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(5); + + private readonly ConcurrentDictionary<(ulong GuildId, Guid ServerId), (DateTimeOffset At, bool Available)> _cache = + new(); + + /// + public string Capability => WorkspaceCapabilities.Clan; + + /// + /// Drops the cached answer for a server. Called by immediately + /// before it reconciles a clan transition, so the reconcile sees the just-written row rather + /// than a stale pre-transition answer. + /// + /// The guild snowflake. + /// The server id. + public void Invalidate(ulong guildId, Guid serverId) => _cache.TryRemove((guildId, serverId), out _); + + /// + public async ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken) + { + // Clans are per-server; the global scope never has clan channels. + if (serverId is not { } id) + { + return false; + } + + var now = clock.UtcNow; + if (_cache.TryGetValue((guildId, id), out var cached) && now - cached.At < CacheTtl) + { + return cached.Available; + } + + // No try/catch: a store failure must fault the reconcile rather than be swallowed into a + // "false" that would delete the clan channels and their history. + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + var available = await store.HasClanAsync(guildId, id, cancellationToken).ConfigureAwait(false); + _cache[(guildId, id)] = (now, available); + return available; + } + } +} diff --git a/src/RustPlusBot.Features.Clans/State/ClanStateService.cs b/src/RustPlusBot.Features.Clans/State/ClanStateService.cs new file mode 100644 index 00000000..398fe6f4 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/State/ClanStateService.cs @@ -0,0 +1,243 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Clans.Messages; +using RustPlusBot.Features.Clans.Names; +using RustPlusBot.Features.Clans.Posting; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Persistence.Clans; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Clans.State; + +/// +/// Applies one observed clan state change: persists it, diffs it against the previous snapshot, +/// posts the resulting feed lines, and reconciles the workspace on a transition. +/// +/// Opens a scope per call for the scoped store, resolver and reconciler. +/// Resolves the #claninfo channel. +/// Posts feed lines. +/// Renders a change into a feed line. +/// Invalidated before a transition reconcile so it sees the fresh row. +/// Drives the score-post throttle. +/// The logger. +internal sealed partial class ClanStateService( + IServiceScopeFactory scopeFactory, + IClanInfoChannelLocator locator, + IClanFeedPoster poster, + ClanChangeRenderer renderer, + ClanCapabilityProvider capability, + IClock clock, + ILogger logger) +{ + /// + /// Minimum spacing between score posts for one server. Score moves on every kill; unthrottled + /// it would drown every other event in the feed. + /// + private static readonly TimeSpan ScoreThrottle = TimeSpan.FromSeconds(60); + + private readonly ConcurrentDictionary<(ulong GuildId, Guid ServerId), DateTimeOffset> _lastScorePost = new(); + + /// Applies one clan state change end to end. + /// The observed change. + /// A cancellation token. + /// A task that completes once the change has been applied. + public async Task ApplyAsync(ClanStateChangedEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + + // "Could not ask" must never be mistaken for "no clan": acting on it would clear the stored + // snapshot and let the reconciler delete the clan channels on a transient socket failure. + if (evt.Status == ClanProbeStatus.Unavailable) + { + return; + } + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var services = scope.ServiceProvider; + var store = services.GetRequiredService(); + + if (evt.Status == ClanProbeStatus.NoClan) + { + await ApplyNoClanAsync(services, store, evt, cancellationToken).ConfigureAwait(false); + return; + } + + if (evt.Snapshot is not { } snapshot) + { + // HasClan without a payload is a contract violation upstream; there is nothing to apply. + LogMissingSnapshot(logger, evt.GuildId, evt.ServerId); + return; + } + + await ApplyHasClanAsync(services, store, evt, snapshot, cancellationToken).ConfigureAwait(false); + } + } + + private static void Collect(HashSet ids, ulong? id) + { + if (id is { } value) + { + ids.Add(value); + } + } + + private async Task ApplyNoClanAsync( + IServiceProvider services, + IClanStore store, + ClanStateChangedEvent evt, + CancellationToken cancellationToken) + { + // Read the outgoing snapshot first: the dissolved line names the clan we are about to forget. + var previous = await store.GetAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + var cleared = await store.ClearAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + if (!cleared) + { + // No row existed, so nothing transitioned — a repeated NoClan probe is not news. + return; + } + + // Post BEFORE reconciling: the reconcile is about to delete the channel we are posting into. + var changes = ClanSnapshotDiffer.Diff(previous, null); + await PostChangesAsync(services, evt, changes, null, cancellationToken).ConfigureAwait(false); + await ReconcileAsync(services, evt, cancellationToken).ConfigureAwait(false); + } + + private async Task ApplyHasClanAsync( + IServiceProvider services, + IClanStore store, + ClanStateChangedEvent evt, + ClanSnapshot snapshot, + CancellationToken cancellationToken) + { + var previous = await store.GetAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + await store.SaveAsync(evt.GuildId, evt.ServerId, snapshot, cancellationToken).ConfigureAwait(false); + + var changes = ClanSnapshotDiffer.Diff(previous, snapshot); + await PostChangesAsync(services, evt, changes, snapshot, cancellationToken).ConfigureAwait(false); + + if (previous is null) + { + // First detection: none→clan is a transition, so the channels have to be created. Every + // later snapshot skips this — OnClanChanged fires on any clan edit and reconciling each + // time would hammer Discord. + await ReconcileAsync(services, evt, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ReconcileAsync( + IServiceProvider services, + ClanStateChangedEvent evt, + CancellationToken cancellationToken) + { + capability.Invalidate(evt.GuildId, evt.ServerId); + var reconciler = services.GetRequiredService(); + await reconciler.ReconcileServerAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + } + + private async Task PostChangesAsync( + IServiceProvider services, + ClanStateChangedEvent evt, + IReadOnlyList changes, + ClanSnapshot? snapshot, + CancellationToken cancellationToken) + { + if (changes.Count == 0) + { + return; + } + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (channelId is not { } channel) + { + // Not provisioned yet (the very first snapshot arrives before the reconcile creates it). + return; + } + + var postable = ApplyScoreThrottle(evt, changes); + if (postable.Count == 0) + { + return; + } + + var workspace = services.GetRequiredService(); + var culture = await workspace.GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + var names = await ResolveNamesAsync(services, evt, postable, snapshot, cancellationToken) + .ConfigureAwait(false); + + foreach (var change in postable) + { + if (renderer.Render(change, names, culture) is { } line) + { + await poster.PostAsync(channel, line, cancellationToken).ConfigureAwait(false); + } + } + } + + private static Task> ResolveNamesAsync( + IServiceProvider services, + ClanStateChangedEvent evt, + IReadOnlyList changes, + ClanSnapshot? snapshot, + CancellationToken cancellationToken) + { + // One batched resolve covering every id the lines can mention. + var ids = new HashSet(); + foreach (var change in changes) + { + Collect(ids, change.SteamId); + Collect(ids, change.ActorSteamId); + } + + if (snapshot is not null) + { + foreach (var member in snapshot.Members) + { + ids.Add(member.SteamId); + } + } + + var resolver = services.GetRequiredService(); + return resolver.ResolveAsync(evt.GuildId, evt.ServerId, ids, cancellationToken); + } + + /// Drops a score change that follows too closely on the last posted one. + /// The change being applied (identifies the server). + /// The diffed changes. + /// The changes that should be posted. + private List ApplyScoreThrottle(ClanStateChangedEvent evt, IReadOnlyList changes) + { + var key = (evt.GuildId, evt.ServerId); + var result = new List(changes.Count); + foreach (var change in changes) + { + if (change.Kind != ClanChangeKind.ScoreChanged) + { + result.Add(change); + continue; + } + + var now = clock.UtcNow; + if (_lastScorePost.TryGetValue(key, out var last) && now - last < ScoreThrottle) + { + continue; + } + + _lastScorePost[key] = now; + result.Add(change); + } + + return result; + } + + [LoggerMessage(Level = LogLevel.Warning, + Message = "A HasClan clan state change for guild {GuildId} server {ServerId} carried no snapshot.")] + private static partial void LogMissingSnapshot(ILogger logger, ulong guildId, Guid serverId); +} diff --git a/src/RustPlusBot.Features.Workspace/Locating/ClanInfoChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/ClanInfoChannelLocator.cs new file mode 100644 index 00000000..187d4f62 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/ClanInfoChannelLocator.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Time; + +namespace RustPlusBot.Features.Workspace.Locating; + +/// Resolves the #claninfo channel id for a (guild, server). +/// Opens scopes for the scoped workspace store. +/// Drives the cache TTL. +internal sealed class ClanInfoChannelLocator(IServiceScopeFactory scopeFactory, IClock clock) + : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerClanInfo), IClanInfoChannelLocator; diff --git a/src/RustPlusBot.Features.Workspace/Locating/IClanInfoChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/IClanInfoChannelLocator.cs new file mode 100644 index 00000000..e3508a23 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/IClanInfoChannelLocator.cs @@ -0,0 +1,15 @@ +namespace RustPlusBot.Features.Workspace.Locating; + +/// +/// Resolves the per-server #claninfo channel (bot-to-Discord direction only). There is no reverse +/// lookup because nothing reads messages back out of #claninfo. +/// +public interface IClanInfoChannelLocator +{ + /// Gets the #claninfo channel id for (, ), or null. + /// The guild snowflake. + /// The server id. + /// A cancellation token. + /// The Discord channel id, or null if not provisioned. + Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj index b78b72bd..fa8f77c3 100644 --- a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj +++ b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj @@ -3,6 +3,8 @@ + + diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index e6c60f4e..1cb7c6c2 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -67,6 +67,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) // Channel locators (singleton with TTL cache; IClock + IServiceScopeFactory provided by the host). services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs b/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs new file mode 100644 index 00000000..4d7e7ffe --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs @@ -0,0 +1,107 @@ +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Clans.Hosting; +using RustPlusBot.Features.Clans.Messages; +using RustPlusBot.Features.Clans.Names; +using RustPlusBot.Features.Clans.Posting; +using RustPlusBot.Features.Clans.State; +using RustPlusBot.Features.Clans.Writing; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence.Clans; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Clans.Tests; + +public sealed class ClansRegistrationTests +{ + [Fact] + public async Task Resolves_every_registered_clan_service() + { + await using var provider = BuildProvider(); + + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + Assert.Contains(provider.GetServices(), h => h is ClansHostedService); + + // Scoped services must resolve from a scope; ValidateScopes proves no singleton captured one. + await using var scope = provider.CreateAsyncScope(); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + } + + [Fact] + public async Task Registers_the_clan_capability_provider() + { + await using var provider = BuildProvider(); + + var providers = provider.GetServices().ToList(); + + // Exactly one: WorkspaceRegistry indexes providers with ToDictionary and throws on a duplicate key. + Assert.Single(providers, p => string.Equals(p.Capability, "clan", StringComparison.Ordinal)); + + // The interface registration and the concrete registration must be the same instance, so the + // state service's cache invalidation is seen by the registry's provider. + Assert.Same(provider.GetRequiredService(), providers[0]); + } + + [Fact] + public async Task Registers_the_three_clan_message_renderers() + { + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + + var keys = scope.ServiceProvider.GetServices().Select(r => r.MessageKey).ToList(); + + Assert.Contains("clan.overview", keys, StringComparer.Ordinal); + Assert.Contains("clan.roster", keys, StringComparer.Ordinal); + Assert.Contains("clan.invites", keys, StringComparer.Ordinal); + } + + [Fact] + public void Does_not_register_a_second_chat_bridge() + { + // Features.Chat owns both the team and clan chat bridges; a relay, webhook poster or dedup + // buffer registered here would double-post every clan chat line. + var services = new ServiceCollection(); + services.AddClans(); + + Assert.DoesNotContain(services, d => + d.ServiceType.Name.Contains("Chat", StringComparison.Ordinal) || + (d.ImplementationType?.Name.Contains("Chat", StringComparison.Ordinal) ?? false)); + } + + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(new DiscordSocketClient()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(Substitute.For()); + services.AddLogging(); + + // Scoped in production; registered scoped here so ValidateScopes catches a captive dependency + // on either singleton (the state service and the capability provider both need the store). + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + + services.AddClans(); + + return services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + } +} diff --git a/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs new file mode 100644 index 00000000..a6df2859 --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs @@ -0,0 +1,228 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Clans.Messages; +using RustPlusBot.Features.Clans.Names; +using RustPlusBot.Features.Clans.Posting; +using RustPlusBot.Features.Clans.State; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Clans; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Clans.Tests.State; + +public sealed class ClanStateServiceTests +{ + private const ulong Guild = 42UL; + private const ulong Channel = 999UL; + private static readonly Guid Server = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + private readonly IClanStore _store = Substitute.For(); + private readonly IWorkspaceStore _workspace = Substitute.For(); + private readonly IClanNameResolver _names = Substitute.For(); + private readonly IWorkspaceReconciler _reconciler = Substitute.For(); + private readonly IClanInfoChannelLocator _locator = Substitute.For(); + private readonly IClanFeedPoster _poster = Substitute.For(); + private readonly ILocalizer _localizer = Substitute.For(); + + public ClanStateServiceTests() + { + _locator.GetChannelIdAsync(Guild, Server, Arg.Any()).Returns((ulong?)Channel); + _workspace.GetCultureAsync(Guild, Arg.Any()).Returns("en"); + _names.ResolveAsync(Guild, Server, Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + + // The clan.event.* keys land in Task 12; echo the key so a rendered line is non-null today. + _localizer.Get(Arg.Any(), Arg.Any()).Returns(ci => (string)ci[0]!); + _localizer.Get(Arg.Any(), Arg.Any(), Arg.Any()).Returns(ci => (string)ci[0]!); + } + + [Fact] + public async Task Unavailable_changes_nothing() + { + var service = Build(); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.Unavailable, null), + CancellationToken.None); + + await _store.DidNotReceive().ClearAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _store.DidNotReceive().SaveAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); + await _reconciler.DidNotReceive() + .ReconcileServerAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task NoClan_clears_the_state_and_reconciles() + { + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot()); + _store.ClearAsync(Guild, Server, Arg.Any()).Returns(true); + var service = Build(); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.NoClan, null), + CancellationToken.None); + + await _store.Received(1).ClearAsync(Guild, Server, Arg.Any()); + await _reconciler.Received(1).ReconcileServerAsync(Guild, Server, Arg.Any()); + } + + [Fact] + public async Task NoClan_posts_the_dissolved_line_before_reconciling() + { + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot()); + _store.ClearAsync(Guild, Server, Arg.Any()).Returns(true); + var service = Build(); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.NoClan, null), + CancellationToken.None); + + // The reconcile deletes the channel we just posted into, so the order is load-bearing. +#pragma warning disable VSTHRD110 // NSubstitute call specifications: the returned tasks are never awaited. + Received.InOrder(() => + { + _poster.PostAsync(Channel, "clan.event.dissolved", Arg.Any()); + _reconciler.ReconcileServerAsync(Guild, Server, Arg.Any()); + }); +#pragma warning restore VSTHRD110 + } + + [Fact] + public async Task NoClan_does_nothing_when_there_was_no_stored_clan() + { + _store.ClearAsync(Guild, Server, Arg.Any()).Returns(false); + var service = Build(); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.NoClan, null), + CancellationToken.None); + + await _reconciler.DidNotReceive() + .ReconcileServerAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task First_detection_saves_and_reconciles_without_posting_changes() + { + _store.GetAsync(Guild, Server, Arg.Any()).Returns((ClanSnapshot?)null); + var snapshot = Snapshot(); + var service = Build(); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, snapshot), + CancellationToken.None); + + await _store.Received(1).SaveAsync(Guild, Server, snapshot, Arg.Any()); + await _reconciler.Received(1).ReconcileServerAsync(Guild, Server, Arg.Any()); + await _poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task A_subsequent_snapshot_saves_and_posts_changes_without_reconciling() + { + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot()); + var renamed = Snapshot("Bears"); + var service = Build(); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, renamed), + CancellationToken.None); + + await _store.Received(1).SaveAsync(Guild, Server, renamed, Arg.Any()); + await _poster.Received(1).PostAsync(Channel, "clan.event.renamed", Arg.Any()); + await _reconciler.DidNotReceive() + .ReconcileServerAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Posts_nothing_when_the_snapshot_is_unchanged() + { + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot()); + var service = Build(); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, Snapshot()), + CancellationToken.None); + + await _poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _reconciler.DidNotReceive() + .ReconcileServerAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Does_not_post_when_the_claninfo_channel_is_not_provisioned() + { + _locator.GetChannelIdAsync(Guild, Server, Arg.Any()).Returns((ulong?)null); + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot()); + var renamed = Snapshot("Bears"); + var service = Build(); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, renamed), + CancellationToken.None); + + await _poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _store.Received(1).SaveAsync(Guild, Server, renamed, Arg.Any()); + } + + [Fact] + public async Task Score_changes_are_throttled_to_one_post_per_minute() + { + var clock = new FakeClock(DateTimeOffset.UnixEpoch); + var service = Build(clock); + + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot(score: 1)); + await service.ApplyAsync( + new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, Snapshot(score: 2)), + CancellationToken.None); + + clock.UtcNow = DateTimeOffset.UnixEpoch.AddSeconds(30); + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot(score: 2)); + await service.ApplyAsync( + new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, Snapshot(score: 3)), + CancellationToken.None); + + clock.UtcNow = DateTimeOffset.UnixEpoch.AddSeconds(90); + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot(score: 3)); + await service.ApplyAsync( + new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, Snapshot(score: 4)), + CancellationToken.None); + + // Three score moves, but the one 30s after the first is dropped. + await _poster.Received(2).PostAsync(Channel, "clan.event.score", Arg.Any()); + } + + private static ClanSnapshot Snapshot(string name = "Wolves", long? score = null) => + new(7L, name, DateTimeOffset.UnixEpoch, 1UL, null, null, null, null, null, null, score, [], [], []); + + private ClanStateService Build(IClock? clock = null) + { + var services = new ServiceCollection(); + services.AddScoped(_ => _store); + services.AddScoped(_ => _workspace); + services.AddScoped(_ => _names); + services.AddScoped(_ => _reconciler); + var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + + var scopeFactory = provider.GetRequiredService(); + var effectiveClock = clock ?? new FakeClock(DateTimeOffset.UnixEpoch); + + return new ClanStateService( + scopeFactory, + _locator, + _poster, + new ClanChangeRenderer(_localizer), + new ClanCapabilityProvider(scopeFactory, effectiveClock), + effectiveClock, + NullLogger.Instance); + } + + private sealed class FakeClock(DateTimeOffset now) : IClock + { + public DateTimeOffset UtcNow { get; set; } = now; + } +} From 99bd1cb7abdb10b61c991368de5afc2f0df665f8 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 11:52:50 +0200 Subject: [PATCH 28/39] Pin the clan capability cache and guard its invalidation race ClanCapabilityProvider had no behavioural tests at all, yet a "false" answer from it makes the workspace reconciler delete #clanchat and #claninfo together with their whole history. Wrapping its store call in a try/catch that returned false passed the entire suite. Add ClanCapabilityProviderTests covering the load-bearing contracts: a store failure faults instead of answering false and is not cached, the global scope answers false without opening a scope or touching the store, the answer follows the stored row, the 5s TTL both serves and expires, and Invalidate drops the entry. Close the TOCTOU window where a read that queried the store before a transition committed could write its stale answer into the cache after Invalidate returned, silently losing the transition reconcile until the next transition or a process restart (WorkspaceHostedService heals only at startup and on ChannelDestroyed). A version counter now guards the cache write: a read that an invalidation overtook simply declines to populate the cache. Pin the invalidation call itself with the real provider wired to a substituted store, so a wrong key or a post-reconcile ordering fails. Pin the InteractionModuleAssembly registration that ClanMotdModule discovery depends on, and give Unavailable_changes_nothing a non-null snapshot case so the early-return guard is genuinely load-bearing rather than shadowed by the missing-snapshot guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../State/ClanCapabilityProvider.cs | 26 ++- .../ClansRegistrationTests.cs | 14 ++ .../State/ClanCapabilityProviderTests.cs | 188 ++++++++++++++++++ .../State/ClanStateServiceTests.cs | 78 ++++++++ 4 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 tests/RustPlusBot.Features.Clans.Tests/State/ClanCapabilityProviderTests.cs diff --git a/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs b/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs index 3477dfdf..79d82689 100644 --- a/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs +++ b/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs @@ -26,6 +26,13 @@ internal sealed class ClanCapabilityProvider(IServiceScopeFactory scopeFactory, private readonly ConcurrentDictionary<(ulong GuildId, Guid ServerId), (DateTimeOffset At, bool Available)> _cache = new(); + /// + /// Bumped by every . A read captures it before querying the store and + /// declines to cache its result if it changed meanwhile, so a read that raced a transition can + /// never resurrect a stale answer after the invalidation. + /// + private long _epoch; + /// public string Capability => WorkspaceCapabilities.Clan; @@ -36,7 +43,12 @@ internal sealed class ClanCapabilityProvider(IServiceScopeFactory scopeFactory, /// /// The guild snowflake. /// The server id. - public void Invalidate(ulong guildId, Guid serverId) => _cache.TryRemove((guildId, serverId), out _); + public void Invalidate(ulong guildId, Guid serverId) + { + // Bump first: an in-flight read that observes the new epoch after its query will not cache. + Interlocked.Increment(ref _epoch); + _cache.TryRemove((guildId, serverId), out _); + } /// public async ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken) @@ -53,6 +65,8 @@ public async ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, Can return cached.Available; } + var epoch = Interlocked.Read(ref _epoch); + // No try/catch: a store failure must fault the reconcile rather than be swallowed into a // "false" that would delete the clan channels and their history. var scope = scopeFactory.CreateAsyncScope(); @@ -60,7 +74,15 @@ public async ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, Can { var store = scope.ServiceProvider.GetRequiredService(); var available = await store.HasClanAsync(guildId, id, cancellationToken).ConfigureAwait(false); - _cache[(guildId, id)] = (now, available); + + // Only publish an answer that no invalidation overtook: a read that started before a + // transition committed would otherwise land its stale value after Invalidate returned, + // and the transition reconcile would then create or delete nothing. + if (Interlocked.Read(ref _epoch) == epoch) + { + _cache[(guildId, id)] = (now, available); + } + return available; } } diff --git a/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs b/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs index 4d7e7ffe..0da450cb 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs @@ -5,8 +5,10 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; +using RustPlusBot.Discord; using RustPlusBot.Features.Clans.Hosting; using RustPlusBot.Features.Clans.Messages; +using RustPlusBot.Features.Clans.Modules; using RustPlusBot.Features.Clans.Names; using RustPlusBot.Features.Clans.Posting; using RustPlusBot.Features.Clans.State; @@ -67,6 +69,18 @@ public async Task Registers_the_three_clan_message_renderers() Assert.Contains("clan.invites", keys, StringComparer.Ordinal); } + [Fact] + public async Task Registers_the_assembly_that_carries_the_clan_interaction_modules() + { + await using var provider = BuildProvider(); + + // Without this the interaction service never discovers ClanMotdModule and the Set MOTD + // button silently stops responding. + Assert.Contains( + provider.GetServices(), + a => a.Assembly == typeof(ClanMotdModule).Assembly); + } + [Fact] public void Does_not_register_a_second_chat_bridge() { diff --git a/tests/RustPlusBot.Features.Clans.Tests/State/ClanCapabilityProviderTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanCapabilityProviderTests.cs new file mode 100644 index 00000000..4179049d --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanCapabilityProviderTests.cs @@ -0,0 +1,188 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Clans.State; +using RustPlusBot.Persistence.Clans; + +namespace RustPlusBot.Features.Clans.Tests.State; + +public sealed class ClanCapabilityProviderTests +{ + private const ulong Guild = 42UL; + private static readonly Guid Server = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + private readonly IClanStore _store = Substitute.For(); + private readonly FakeClock _clock = new(DateTimeOffset.UnixEpoch); + + [Fact] + public async Task A_store_failure_faults_instead_of_answering_false() + { + // A "false" here makes the workspace reconciler DELETE #clanchat and #claninfo along with + // all their history. On a transient store failure, failing loudly is the only safe answer. + _store.HasClanAsync(Guild, Server, Arg.Any()) + .ThrowsAsync(new InvalidOperationException("store down")); + await using var host = Build(); + + var ex = await Assert.ThrowsAsync( + async () => await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + + Assert.Equal("store down", ex.Message); + } + + [Fact] + public async Task A_failed_read_is_not_cached() + { + _store.HasClanAsync(Guild, Server, Arg.Any()) + .ThrowsAsync(new InvalidOperationException("store down")); + await using var host = Build(); + + await Assert.ThrowsAsync( + async () => await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + await Assert.ThrowsAsync( + async () => await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + + await _store.Received(2).HasClanAsync(Guild, Server, Arg.Any()); + } + + [Fact] + public async Task The_global_scope_is_unavailable_without_touching_the_store() + { + await using var host = Build(); + + Assert.False(await host.Provider.IsAvailableAsync(Guild, null, CancellationToken.None)); + + // Clans are per-server; the global scope never has clan channels, so not even a DI scope + // should be opened for it. + Assert.Equal(0, host.Factory.Created); + await _store.DidNotReceive() + .HasClanAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task The_answer_follows_the_stored_clan_row(bool hasClan) + { + _store.HasClanAsync(Guild, Server, Arg.Any()).Returns(hasClan); + await using var host = Build(); + + Assert.Equal(hasClan, await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + } + + [Fact] + public async Task A_second_call_inside_the_ttl_is_served_from_the_cache() + { + _store.HasClanAsync(Guild, Server, Arg.Any()).Returns(true); + await using var host = Build(); + + Assert.True(await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + _clock.UtcNow = DateTimeOffset.UnixEpoch.AddSeconds(4); + Assert.True(await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + + await _store.Received(1).HasClanAsync(Guild, Server, Arg.Any()); + Assert.Equal(1, host.Factory.Created); + } + + [Fact] + public async Task A_call_after_the_ttl_re_reads_the_store() + { + _store.HasClanAsync(Guild, Server, Arg.Any()).Returns(true); + await using var host = Build(); + + Assert.True(await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + + _clock.UtcNow = DateTimeOffset.UnixEpoch.AddSeconds(6); + _store.HasClanAsync(Guild, Server, Arg.Any()).Returns(false); + + Assert.False(await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + await _store.Received(2).HasClanAsync(Guild, Server, Arg.Any()); + } + + [Fact] + public async Task Invalidate_drops_the_cached_answer() + { + _store.HasClanAsync(Guild, Server, Arg.Any()).Returns(true); + await using var host = Build(); + + Assert.True(await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + + host.Provider.Invalidate(Guild, Server); + _store.HasClanAsync(Guild, Server, Arg.Any()).Returns(false); + + Assert.False(await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + } + + [Fact] + public async Task A_read_that_raced_an_invalidation_does_not_populate_the_cache() + { + await using var host = Build(); + + // Simulates the TOCTOU window deterministically: this read queried the store BEFORE the + // transition committed, and the invalidation lands while it is still in flight. Writing its + // now-stale answer into the cache would make the transition reconcile create or delete + // nothing, and WorkspaceHostedService only heals at startup and on ChannelDestroyed — so the + // lost reconcile would persist until the next transition or a process restart. + var raced = false; + _store.HasClanAsync(Guild, Server, Arg.Any()).Returns(_ => + { + if (raced) + { + return false; + } + + raced = true; + host.Provider.Invalidate(Guild, Server); + return true; + }); + + Assert.True(await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + + // Same instant, so the TTL cannot explain a re-read: the raced value must not have been cached. + Assert.False(await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + await _store.Received(2).HasClanAsync(Guild, Server, Arg.Any()); + } + + private Host Build() + { + var services = new ServiceCollection(); + services.AddScoped(_ => _store); + var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + + var factory = new CountingScopeFactory(provider.GetRequiredService()); + return new Host(provider, factory, new ClanCapabilityProvider(factory, _clock)); + } + + private sealed class Host( + ServiceProvider provider, + CountingScopeFactory factory, + ClanCapabilityProvider capabilityProvider) : IAsyncDisposable + { + public CountingScopeFactory Factory => factory; + + public ClanCapabilityProvider Provider => capabilityProvider; + + public ValueTask DisposeAsync() => provider.DisposeAsync(); + } + + private sealed class CountingScopeFactory(IServiceScopeFactory inner) : IServiceScopeFactory + { + private int _created; + + public int Created => _created; + + public IServiceScope CreateScope() + { + Interlocked.Increment(ref _created); + return inner.CreateScope(); + } + } + + private sealed class FakeClock(DateTimeOffset now) : IClock + { + public DateTimeOffset UtcNow { get; set; } = now; + } +} diff --git a/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs index a6df2859..66c2a0cb 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs @@ -58,6 +58,84 @@ await _reconciler.DidNotReceive() await _poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); } + [Fact] + public async Task Unavailable_carrying_a_snapshot_still_changes_nothing() + { + // The null-snapshot case above is also caught by the missing-snapshot guard, so it does not + // prove the Unavailable early-return exists. A transient probe failure that happens to carry + // the last known payload must still not be persisted, posted or reconciled. + var service = Build(); + + await service.ApplyAsync( + new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.Unavailable, Snapshot()), + CancellationToken.None); + + await _store.DidNotReceive().ClearAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _store.DidNotReceive().SaveAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); + await _store.DidNotReceive().GetAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _reconciler.DidNotReceive() + .ReconcileServerAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task A_transition_invalidates_the_real_capability_cache_before_reconciling() + { + // The real provider, not a substitute: this pins that Invalidate is called, with the right + // key, and before the reconcile — the whole justification for the cache existing. + var clock = new FakeClock(DateTimeOffset.UnixEpoch); + var services = new ServiceCollection(); + services.AddScoped(_ => _store); + services.AddScoped(_ => _workspace); + services.AddScoped(_ => _names); + services.AddScoped(_ => _reconciler); + await using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + var scopeFactory = provider.GetRequiredService(); + var capability = new ClanCapabilityProvider(scopeFactory, clock); + + // Prime the cache while the clan still exists. + _store.HasClanAsync(Guild, Server, Arg.Any()).Returns(true); + Assert.True(await capability.IsAvailableAsync(Guild, Server, CancellationToken.None)); + + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot()); + _store.ClearAsync(Guild, Server, Arg.Any()).Returns(true); + _store.HasClanAsync(Guild, Server, Arg.Any()).Returns(false); + + // What the reconciler sees is the point: an invalidation that lands after it runs is as + // useless as none at all, so the answer is sampled from inside the reconcile. + bool? seenByReconcile = null; + + async Task SampleAsync() + { + seenByReconcile = await capability.IsAvailableAsync(Guild, Server, CancellationToken.None); + return ReconcileResult.Provisioned; + } + + _reconciler.ReconcileServerAsync(Guild, Server, Arg.Any()) + .Returns(_ => SampleAsync()); + + var service = new ClanStateService( + scopeFactory, + _locator, + _poster, + new ClanChangeRenderer(_localizer), + capability, + clock, + NullLogger.Instance); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.NoClan, null), + CancellationToken.None); + + // Still inside the 5s TTL: only a correctly keyed invalidation, made before the reconcile, + // can produce the fresh answer. + Assert.Equal(false, seenByReconcile); + Assert.False(await capability.IsAvailableAsync(Guild, Server, CancellationToken.None)); + } + [Fact] public async Task NoClan_clears_the_state_and_reconciles() { From 3d8648aadbbe6460f50d09c67c242732f54898ba Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 12:11:28 +0200 Subject: [PATCH 29/39] Localize the clan channels, embeds and change feed Add the English and French strings for the clan channel names, the three anchored embeds, the MOTD modal, and every clan feed event. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/RustPlusBot.Localization/Strings.fr.resx | 144 ++++++++++++++++++ src/RustPlusBot.Localization/Strings.resx | 144 ++++++++++++++++++ .../StringsResourceParityTests.cs | 2 +- 3 files changed, 289 insertions(+), 1 deletion(-) diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 26889e45..1983b1d5 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -84,6 +84,12 @@ alarmes + + clanchat + + + clan-info + evenements @@ -111,6 +117,144 @@ tchat-equipe + + Le clan a changé de couleur + + + {0} a été rétrogradé à {1} + + + {0} a été dissous + + + {0} a accepté son invitation + + + {0} a été invité par {1} + + + L'invitation de {0} a été révoquée + + + {0} a rejoint le clan + + + {0} a quitté le clan + + + Le clan a changé de logo + + + {0} a défini le MOTD : {1} + + + Le MOTD a été effacé + + + {0} a été promu {1} + + + Le clan a été renommé en {0} + + + Le score du clan est maintenant de {0} + + + {0} — invité par {1} · {2} + + + 📨 Invitations en attente ({0}) + + + Impossible de mettre à jour le MOTD. Réessayez plus tard. + + + Vous n'avez pas la permission de définir le MOTD du clan. + + + MOTD mis à jour. + + + Créé + + + Créateur + + + Chef + + + Membres + + + Message du jour + + + — {0} · {1} + + + Aucun message du jour défini. + + + Score + + + Définir le MOTD + + + 🛡️ {0} + + + Inconnu + + + Aucun membre. + + + membre depuis {0} + + + vu pour la dernière fois {0} + + + · {0} + + + Voir les journaux + + + Voir les événements de score + + + Rétrograder + + + Inviter + + + Exclure + + + Promouvoir + + + Définir le logo + + + Définir le MOTD + + + Définir les notes + + + 👥 Effectif ({0}) + + + …et {0} de plus + + + Rôle inconnu + {0} ({1}) diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 70195253..4addd7a4 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -84,6 +84,12 @@ alarms + + clanchat + + + clan-info + events @@ -111,6 +117,144 @@ teamchat + + The clan changed its color + + + {0} was demoted to {1} + + + {0} was dissolved + + + {0} accepted their invite + + + {0} was invited by {1} + + + {0}'s invite was revoked + + + {0} joined the clan + + + {0} left the clan + + + The clan changed its logo + + + {0} set the MOTD: {1} + + + The MOTD was cleared + + + {0} was promoted to {1} + + + The clan was renamed to {0} + + + Clan score is now {0} + + + {0} — invited by {1} · {2} + + + 📨 Pending invites ({0}) + + + Couldn't update the MOTD. Try again later. + + + You don't have permission to set the clan MOTD. + + + MOTD updated. + + + Created + + + Creator + + + Leader + + + Members + + + Message of the day + + + — {0} · {1} + + + No message of the day set. + + + Score + + + Set MOTD + + + 🛡️ {0} + + + Unknown + + + No members. + + + member since {0} + + + last seen {0} + + + · {0} + + + View logs + + + View score events + + + Demote + + + Invite + + + Kick + + + Promote + + + Set logo + + + Set MOTD + + + Set notes + + + 👥 Roster ({0}) + + + …and {0} more + + + Unknown role + {0} ({1}) diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index 23e5b1ef..ae64f24a 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(315, EnglishKeys().Count); + Assert.Equal(363, EnglishKeys().Count); } } From 6324f4c6ce7d84df47fc794eb6e10d9a0c482919 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 12:12:19 +0200 Subject: [PATCH 30/39] Translate the French clan channel names Every other channel name is localized per locale (tchat-equipe, moniteurs-stockage, interrupteurs), so the clan channels should be too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/RustPlusBot.Localization/Strings.fr.resx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 1983b1d5..c05706d5 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -85,10 +85,10 @@ alarmes - clanchat + tchat-clan - clan-info + infos-clan evenements From 6a768d2c6789937fe8a2a037f1d77c992f9ec9a3 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 12:18:33 +0200 Subject: [PATCH 31/39] Align the French clan-info channel name with its sibling channel.info.name is the singular "info", so the clan variant reads info-clan rather than introducing a third plural form. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/RustPlusBot.Localization/Strings.fr.resx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index c05706d5..f190a113 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -88,7 +88,7 @@ tchat-clan - infos-clan + info-clan evenements From 70374af3b9418b7a1b855c6fd45538143363edf2 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 12:31:28 +0200 Subject: [PATCH 32/39] Compose the clan module into the host Register AddClans, document the feature and its API limits in the README, and apply the repository formatting profile. Full suite: 1217 passed, 1 skipped, 0 failed across 19 test assemblies (Clans 82, Connections 112, Chat 54, Workspace 102, Persistence 125, Commands 149, Alarms 67, ItemData.Generator 63, Pairing 56, ItemData 53, Events 52, Abstractions 48, Switches 40, StorageMonitors 37, Wipes 26, Players 22, Discord 20, Localization 12, Map 97+1 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 23 ++++- .../Hosting/ChatHostedService.cs | 2 +- .../Messages/ClanOverviewMessageRenderer.cs | 5 +- .../State/ClanCapabilityProvider.cs | 28 +++---- .../State/ClanSnapshotDiffer.cs | 3 +- .../Supervisor/ConnectionSupervisor.cs | 84 +++++++++---------- .../Registry/WorkspaceRegistry.cs | 6 +- src/RustPlusBot.Host/Program.cs | 2 + src/RustPlusBot.Host/RustPlusBot.Host.csproj | 1 + .../Clans/ClanSnapshotSerializer.cs | 1 - .../ClanPlayerNameConfiguration.cs | 5 +- .../ChatRelayTests.cs | 3 +- .../Messages/ClanRendererTests.cs | 20 ++++- .../Names/ClanNameResolverTests.cs | 10 ++- .../State/ClanCapabilityProviderTests.cs | 14 ++-- .../State/ClanSnapshotDifferTests.cs | 28 +++---- .../State/ClanStateServiceTests.cs | 10 +-- .../ClanMappingTests.cs | 29 ++++++- .../ClanSupervisorTests.cs | 1 - .../Fakes/FakeRustSocketSource.cs | 14 ++-- .../Fakes/FakeWorkspaceGateway.cs | 1 + 21 files changed, 180 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 80ec0790..b3155e44 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,26 @@ recycle/craft/research/decay/upkeep calculators are all shipped. Cameras are nex - Two-way relay between in-game team chat and a per-server `#teamchat` channel (via a managed webhook), with echo/loop suppression. +### Clans + +- **Conditional channels** — `#clanchat` and `#claninfo` appear automatically when + the paired player is in a clan, and are removed again when they leave; no + command to run either way. +- **`#clanchat`** — two-way relay between in-game clan chat and the channel, sharing + the same echo/loop suppression as the team bridge. +- **`#claninfo`** — three pinned auto-refreshing embeds — **Overview** (score, + member count, creation date, leader, creator, MOTD), **Roster** (members grouped + by clan role, online first, with each role's permissions), and **Invites** — + refreshed on the existing `Workspace:InfoRefreshInterval`, plus a live feed of + clan changes (members joining/leaving, promotions/demotions, invites, rename, + MOTD, logo, colour, score, dissolution). +- **Set MOTD** — a button on the overview embed opens a modal that writes the MOTD + back to the game; it is offered only when the paired player's in-game clan role + carries the permission. +- **API limits** — RustPlusApi 2.0.0-beta.4 exposes no clan audit log, no per-member + scores, and no kick/invite/promote actions, so none of those are implemented; the + feed is instead derived by diffing successive clan snapshots. + ### In-game `!commands` Run in team chat by any teammate; replies in the guild's language with a @@ -128,7 +148,8 @@ See [docs/development/running-locally.md](docs/development/running-locally.md). | `RustPlusBot.Features.Workspace` | Channel/message provisioning, reconciler, `#info`/`#setup`/`#settings` surfaces | | `RustPlusBot.Features.Pairing` | FCM pairing listener, credential intake, account disconnect | | `RustPlusBot.Features.Connections` | Live socket supervisor, hot-swap/failover, Rust+ query seam | -| `RustPlusBot.Features.Chat` | Two-way `#teamchat` ↔ in-game chat bridge | +| `RustPlusBot.Features.Chat` | Two-way `#teamchat` / `#clanchat` ↔ in-game chat bridges | +| `RustPlusBot.Features.Clans` | Clan state, `#claninfo` embeds and change feed, Set MOTD | | `RustPlusBot.Features.Commands` | In-game `!commands`, slash surfaces, `/help`/`/leader` | | `RustPlusBot.Features.Events` | Live map-event classification + `#events` feed | | `RustPlusBot.Features.Map` | Map image rendering with toggleable layers | diff --git a/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs b/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs index e64646ac..04ad9969 100644 --- a/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs +++ b/src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs @@ -27,8 +27,8 @@ internal sealed partial class ChatHostedService( ILogger logger) : IHostedService, IDisposable { private readonly CancellationTokenSource _cts = new(); - private Task? _teamLoop; private Task? _clanLoop; + private Task? _teamLoop; /// public void Dispose() => _cts.Dispose(); diff --git a/src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs b/src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs index f2f1839d..67f9cf75 100644 --- a/src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs +++ b/src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs @@ -120,7 +120,10 @@ private Task> ResolveNamesAsync( CancellationToken cancellationToken) { // One batched call: the embed needs at most the leader, the creator and the MOTD author. - var ids = new HashSet { clan.Creator }; + var ids = new HashSet + { + clan.Creator + }; if (leader is not null) { ids.Add(leader.SteamId); diff --git a/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs b/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs index 79d82689..c286234c 100644 --- a/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs +++ b/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs @@ -36,20 +36,6 @@ internal sealed class ClanCapabilityProvider(IServiceScopeFactory scopeFactory, /// public string Capability => WorkspaceCapabilities.Clan; - /// - /// Drops the cached answer for a server. Called by immediately - /// before it reconciles a clan transition, so the reconcile sees the just-written row rather - /// than a stale pre-transition answer. - /// - /// The guild snowflake. - /// The server id. - public void Invalidate(ulong guildId, Guid serverId) - { - // Bump first: an in-flight read that observes the new epoch after its query will not cache. - Interlocked.Increment(ref _epoch); - _cache.TryRemove((guildId, serverId), out _); - } - /// public async ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken) { @@ -86,4 +72,18 @@ public async ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, Can return available; } } + + /// + /// Drops the cached answer for a server. Called by immediately + /// before it reconciles a clan transition, so the reconcile sees the just-written row rather + /// than a stale pre-transition answer. + /// + /// The guild snowflake. + /// The server id. + public void Invalidate(ulong guildId, Guid serverId) + { + // Bump first: an in-flight read that observes the new epoch after its query will not cache. + Interlocked.Increment(ref _epoch); + _cache.TryRemove((guildId, serverId), out _); + } } diff --git a/src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs b/src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs index f878be12..9aab38eb 100644 --- a/src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs +++ b/src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs @@ -53,7 +53,8 @@ public static IReadOnlyList Diff(ClanSnapshot? previous, ClanSnapsho // An id that left Invites and appeared in Members in one step is an acceptance, reported // once — never as a join plus a revocation. var accepted = previousInvites - .Where(id => !currentInvites.Contains(id) && currentMembers.ContainsKey(id) && !previousMembers.ContainsKey(id)) + .Where(id => + !currentInvites.Contains(id) && currentMembers.ContainsKey(id) && !previousMembers.ContainsKey(id)) .ToHashSet(); foreach (var id in currentMembers.Keys.Where(id => !previousMembers.ContainsKey(id) && !accepted.Contains(id)) diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 792e1416..51a98166 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -89,6 +89,48 @@ public async ValueTask DisposeAsync() _gate.Dispose(); } + /// + public async Task SendAsync( + ChatChannelKind kind, + ulong guildId, + Guid serverId, + string message, + CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return ChatSendResult.NotConnected; + } + + try + { + switch (kind) + { + case ChatChannelKind.Team: + await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); + break; + case ChatChannelKind.Clan: + await live.Connection.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false); + break; + default: + return ChatSendResult.Failed; + } + + return ChatSendResult.Sent; + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogSendFailed(logger, ex, serverId); + return ChatSendResult.Failed; + } + } + /// public async Task StartAllAsync(CancellationToken cancellationToken = default) { @@ -380,48 +422,6 @@ public async Task SetClanMotdAsync( .ConfigureAwait(false); } - /// - public async Task SendAsync( - ChatChannelKind kind, - ulong guildId, - Guid serverId, - string message, - CancellationToken cancellationToken) - { - if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) - { - return ChatSendResult.NotConnected; - } - - try - { - switch (kind) - { - case ChatChannelKind.Team: - await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); - break; - case ChatChannelKind.Clan: - await live.Connection.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false); - break; - default: - return ChatSendResult.Failed; - } - - return ChatSendResult.Sent; - } - catch (OperationCanceledException) - { - throw; - } -#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed. - catch (Exception ex) -#pragma warning restore CA1031 - { - LogSendFailed(logger, ex, serverId); - return ChatSendResult.Failed; - } - } - [LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")] private static partial void LogLoopFaulted(ILogger logger, Exception exception, Guid serverId); diff --git a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs index 740a05a7..55ed7189 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs @@ -9,12 +9,12 @@ internal sealed class WorkspaceRegistry( IEnumerable messageProviders, IEnumerable capabilityProviders) : IWorkspaceRegistry { - private readonly List _channels = [.. channelProviders.SelectMany(p => p.GetChannelSpecs())]; - private readonly List _messages = [.. messageProviders.SelectMany(p => p.GetMessageSpecs())]; - private readonly Dictionary _capabilities = capabilityProviders.ToDictionary(p => p.Capability, StringComparer.Ordinal); + private readonly List _channels = [.. channelProviders.SelectMany(p => p.GetChannelSpecs())]; + private readonly List _messages = [.. messageProviders.SelectMany(p => p.GetMessageSpecs())]; + /// public ValueTask IsCapabilityAvailableAsync(string capability, ulong guildId, diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index d75b5e02..fb616a47 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -5,6 +5,7 @@ using RustPlusBot.Discord; using RustPlusBot.Features.Alarms; using RustPlusBot.Features.Chat; +using RustPlusBot.Features.Clans; using RustPlusBot.Features.Commands; using RustPlusBot.Features.Connections; using RustPlusBot.Features.Events; @@ -69,6 +70,7 @@ .ValidateOnStart(); builder.Services.AddConnections(); builder.Services.AddChat(); +builder.Services.AddClans(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Commands")) .Validate(static o => o.Cooldown > TimeSpan.Zero, "Commands:Cooldown must be positive.") diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj index 1f7839bd..6b1f9ef8 100644 --- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj +++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj @@ -26,6 +26,7 @@ + diff --git a/src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs b/src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs index ac12f3ac..179f1a4b 100644 --- a/src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs +++ b/src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using RustPlusBot.Abstractions.Connections; namespace RustPlusBot.Persistence.Clans; diff --git a/src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.cs index e9534974..207f667b 100644 --- a/src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.cs +++ b/src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.cs @@ -10,7 +10,10 @@ internal sealed class ClanPlayerNameConfiguration : IEntityTypeConfiguration builder) { ArgumentNullException.ThrowIfNull(builder); - builder.HasKey(n => new { n.ServerId, n.SteamId }); + builder.HasKey(n => new + { + n.ServerId, n.SteamId + }); builder.Property(n => n.Name).HasMaxLength(64); builder.HasOne() diff --git a/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs index 50a9127b..1496445a 100644 --- a/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs @@ -115,7 +115,8 @@ public async Task Posts_an_active_player_line_that_is_not_an_echo(ChatChannelKin await relay.RelayAsync(line, CancellationToken.None); - await poster.Received(1).PostAsync(kind, ChannelFor(kind), "BotPlayer", "genuine", Arg.Any()); + await poster.Received(1) + .PostAsync(kind, ChannelFor(kind), "BotPlayer", "genuine", Arg.Any()); } [Theory] diff --git a/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs b/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs index bd16c2a2..1d3b44e5 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs @@ -78,7 +78,10 @@ public async Task Overview_shows_the_motd_and_its_author() motd: "Hold the fort", motdAuthor: 2UL, members: [Member(1UL, 1), Member(2UL, 2)]); - var resolver = Resolver(new Dictionary { [2UL] = "Grace" }); + var resolver = Resolver(new Dictionary + { + [2UL] = "Grace" + }); var renderer = new ClanOverviewMessageRenderer(Store(clan), resolver, Substitute.For(), Localizer()); @@ -325,7 +328,10 @@ public async Task Roster_falls_back_to_a_profile_link_for_an_unknown_name() { const string link = "[1](https://steamcommunity.com/profiles/1)"; var clan = Clan(roles: [Role(1, 0, "Leader")], members: [Member(1UL, 1)]); - var resolver = Resolver(new Dictionary { [1UL] = link }); + var resolver = Resolver(new Dictionary + { + [1UL] = link + }); var renderer = new ClanRosterMessageRenderer(Store(clan), resolver, Localizer()); var payload = await renderer.RenderAsync(Context, CancellationToken.None); @@ -395,7 +401,10 @@ public async Task Invites_returns_an_empty_payload_when_there_are_none() public async Task Invites_lists_the_invitee_and_the_recruiter() { var clan = Clan(invites: [new ClanInviteSnapshot(5UL, 9UL, DateTimeOffset.UnixEpoch)]); - var resolver = Resolver(new Dictionary { [5UL] = "Newbie", [9UL] = "Grace" }); + var resolver = Resolver(new Dictionary + { + [5UL] = "Newbie", [9UL] = "Grace" + }); var renderer = new ClanInvitesMessageRenderer(Store(clan), resolver, Localizer()); var payload = await renderer.RenderAsync(Context, CancellationToken.None); @@ -435,7 +444,10 @@ private static IConnectionStore Connections(ulong steamId) { var connections = Substitute.For(); connections.GetActiveCredentialAsync(Guild, Server, Arg.Any()) - .Returns(Task.FromResult(new PlayerCredential { SteamId = steamId })); + .Returns(Task.FromResult(new PlayerCredential + { + SteamId = steamId + })); return connections; } diff --git a/tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs b/tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs index ca7a949c..c5b5bd18 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs @@ -16,7 +16,10 @@ public async Task Returns_cached_names_for_known_ids() var store = Substitute.For(); store.GetNamesAsync(Guild, Server, Arg.Any>(), Arg.Any()) .Returns(Task.FromResult>( - new Dictionary { [1UL] = "Ada", [2UL] = "Grace" })); + new Dictionary + { + [1UL] = "Ada", [2UL] = "Grace" + })); var resolved = await new ClanNameResolver(store) .ResolveAsync(Guild, Server, [1UL, 2UL], CancellationToken.None); @@ -46,7 +49,10 @@ public async Task Covers_every_requested_id() var store = Substitute.For(); store.GetNamesAsync(Guild, Server, Arg.Any>(), Arg.Any()) .Returns(Task.FromResult>( - new Dictionary { [1UL] = "Ada", [3UL] = " " })); + new Dictionary + { + [1UL] = "Ada", [3UL] = " " + })); var resolved = await new ClanNameResolver(store) .ResolveAsync(Guild, Server, [1UL, 2UL, 3UL], CancellationToken.None); diff --git a/tests/RustPlusBot.Features.Clans.Tests/State/ClanCapabilityProviderTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanCapabilityProviderTests.cs index 4179049d..3821153f 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/State/ClanCapabilityProviderTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanCapabilityProviderTests.cs @@ -11,9 +11,9 @@ public sealed class ClanCapabilityProviderTests { private const ulong Guild = 42UL; private static readonly Guid Server = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private readonly FakeClock _clock = new(DateTimeOffset.UnixEpoch); private readonly IClanStore _store = Substitute.For(); - private readonly FakeClock _clock = new(DateTimeOffset.UnixEpoch); [Fact] public async Task A_store_failure_faults_instead_of_answering_false() @@ -24,8 +24,8 @@ public async Task A_store_failure_faults_instead_of_answering_false() .ThrowsAsync(new InvalidOperationException("store down")); await using var host = Build(); - var ex = await Assert.ThrowsAsync( - async () => await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + var ex = await Assert.ThrowsAsync(async () => + await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); Assert.Equal("store down", ex.Message); } @@ -37,10 +37,10 @@ public async Task A_failed_read_is_not_cached() .ThrowsAsync(new InvalidOperationException("store down")); await using var host = Build(); - await Assert.ThrowsAsync( - async () => await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); - await Assert.ThrowsAsync( - async () => await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + await Assert.ThrowsAsync(async () => + await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); + await Assert.ThrowsAsync(async () => + await host.Provider.IsAvailableAsync(Guild, Server, CancellationToken.None)); await _store.Received(2).HasClanAsync(Guild, Server, Arg.Any()); } diff --git a/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs index 42430f0a..846d98fb 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs @@ -16,20 +16,20 @@ private static ClanSnapshot Clan( IReadOnlyList? roles = null, IReadOnlyList? members = null, IReadOnlyList? invites = null) => new( - clanId, - name, - DateTimeOffset.UnixEpoch, - 1, - motd, - motd is null ? null : DateTimeOffset.UnixEpoch, - motdAuthor, - logoHash, - color, - null, - score, - roles ?? [Role(1, 0, "Member")], - members ?? [], - invites ?? []); + clanId, + name, + DateTimeOffset.UnixEpoch, + 1, + motd, + motd is null ? null : DateTimeOffset.UnixEpoch, + motdAuthor, + logoHash, + color, + null, + score, + roles ?? [Role(1, 0, "Member")], + members ?? [], + invites ?? []); private static ClanRoleSnapshot Role(int roleId, int rank, string name) => new(roleId, rank, name, false, false, false, false, false, false, false, false, false); diff --git a/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs index 66c2a0cb..531d139d 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs @@ -21,14 +21,14 @@ public sealed class ClanStateServiceTests private const ulong Guild = 42UL; private const ulong Channel = 999UL; private static readonly Guid Server = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private readonly ILocalizer _localizer = Substitute.For(); + private readonly IClanInfoChannelLocator _locator = Substitute.For(); + private readonly IClanNameResolver _names = Substitute.For(); + private readonly IClanFeedPoster _poster = Substitute.For(); + private readonly IWorkspaceReconciler _reconciler = Substitute.For(); private readonly IClanStore _store = Substitute.For(); private readonly IWorkspaceStore _workspace = Substitute.For(); - private readonly IClanNameResolver _names = Substitute.For(); - private readonly IWorkspaceReconciler _reconciler = Substitute.For(); - private readonly IClanInfoChannelLocator _locator = Substitute.For(); - private readonly IClanFeedPoster _poster = Substitute.For(); - private readonly ILocalizer _localizer = Substitute.For(); public ClanStateServiceTests() { diff --git a/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs index 5809b4f3..52e2b90a 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs @@ -57,7 +57,13 @@ public void Maps_a_populated_clan_to_a_snapshot() Color = 255, MaxMemberCount = 8, Score = 1234, - Roles = [new ClanRole { RoleId = 1, Rank = 0, Name = "Leader", CanSetMotd = true }], + Roles = + [ + new ClanRole + { + RoleId = 1, Rank = 0, Name = "Leader", CanSetMotd = true + } + ], Members = [ new ClanMember @@ -70,7 +76,13 @@ public void Maps_a_populated_clan_to_a_snapshot() Online = true, }, ], - Invites = [new ClanInvite { SteamId = 11UL, Recruiter = 7UL, Timestamp = new DateTime(2026, 3, 2, 0, 0, 0, DateTimeKind.Utc) }], + Invites = + [ + new ClanInvite + { + SteamId = 11UL, Recruiter = 7UL, Timestamp = new DateTime(2026, 3, 2, 0, 0, 0, DateTimeKind.Utc) + } + ], }; var result = ClanMapping.FromResponse(true, null, info); @@ -109,7 +121,13 @@ public void Maps_a_populated_clan_to_a_snapshot() public void Treats_a_null_Online_flag_as_offline() { // ClanMember.Online is bool? in the API; null must not be rendered as online. - var info = NewClan(members: [new ClanMember { SteamId = 7UL, RoleId = 1, Online = null }]); + var info = NewClan(members: + [ + new ClanMember + { + SteamId = 7UL, RoleId = 1, Online = null + } + ]); var result = ClanMapping.FromResponse(true, null, info); @@ -119,7 +137,10 @@ public void Treats_a_null_Online_flag_as_offline() [Fact] public void Treats_timestamps_as_utc() { - var info = NewClan() with { Created = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Unspecified) }; + var info = NewClan() with + { + Created = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Unspecified) + }; var result = ClanMapping.FromResponse(true, null, info); diff --git a/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs index 2b297348..4b9b92a1 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs @@ -9,7 +9,6 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; using RustPlusBot.Discord.Notifications; -using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; using RustPlusBot.Domain.Servers; using RustPlusBot.Features.Connections.Listening; diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index daeeeef3..a84c9677 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -27,8 +27,8 @@ internal sealed class FakeRustSocketSource : IRustSocketSource private int _createCount; private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0); - private IReadOnlyList _pendingMonuments = []; private ClanProbeResult? _pendingClanProbe; + private IReadOnlyList _pendingMonuments = []; /// Number of times has been called. Safe to read from any thread. public int CreateCount => Volatile.Read(ref _createCount); @@ -247,6 +247,12 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// The bytes returned by . Defaults to null. public byte[]? MapImageResult { get; set; } + /// The probe result this fake returns; defaults to no clan. + public ClanProbeResult ClanProbe { get; set; } = ClanProbeResult.NoClan; + + /// Messages sent to in-game clan chat through this fake. + public List SentClanMessages { get; } = []; + /// Raised when a team chat message arrives on this connection. public event EventHandler? TeamMessageReceived; @@ -283,12 +289,6 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT return Task.CompletedTask; } - /// The probe result this fake returns; defaults to no clan. - public ClanProbeResult ClanProbe { get; set; } = ClanProbeResult.NoClan; - - /// Messages sent to in-game clan chat through this fake. - public List SentClanMessages { get; } = []; - public Task GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(ClanProbe); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs index d487f975..e69247df 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs @@ -19,6 +19,7 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway /// When true, throws (pin-failure path). public bool ThrowOnPin { get; set; } + public int CreatedCategories { get; private set; } public int CreatedChannels { get; private set; } public int PostedMessages { get; private set; } From bf8fccbbbe5655828fa5dd531d82e0e364786385 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 13:16:30 +0200 Subject: [PATCH 33/39] Fix the cross-cutting clan defects the whole-branch review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render an explicit "no pending invites" embed when a clan is stored but has none. An empty payload means "leave the previous message on screen" to both the reconciler and ServerInfoRefresher, so the pinned invites embed listed resolved invitations forever. The two paths that must stay empty — no server id, and no clan stored — are unchanged, since a non-empty payload on a clanless server would trigger a full reconcile on every refresh tick. Isolate each clan state change in its own try/catch inside the consumer loop. The broad catch sat outside the await foreach, so one transient store failure or Discord 5xx ended the loop for the life of the process, after which the clan channels were never created and, worse, never torn down again. Harvest member names from the live team snapshot on the HasClan path, the second of the two name sources the design calls for. Without it a fresh install renders every roster entry, leader, creator and feed line as a bare Steam profile link until that member happens to speak in clan chat. It is best-effort: a disconnected socket or a failure never blocks persistence, the reconcile or the feed. Refuse to build a WorkspaceRegistry whose channel specs name a capability no provider answers for. The reconciler reads "no provider" as "unavailable", and unavailable deletes the channel and its history, so a host composing AddWorkspace() without AddClans() would silently destroy every guild's #clanchat and #claninfo on its first heal. It must fail to start instead. Budget the roster embed's total text. Per-field 1024 truncation left the 6000-char whole-embed cap unguarded, and EmbedBuilder.Build() throws rather than trimming; six role groups at their ceiling breach it. Groups are now added while a running total fits, and a localized notice names how many were omitted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Hosting/ClansHostedService.cs | 27 ++++- .../Messages/ClanInvitesMessageRenderer.cs | 26 ++++- .../Messages/ClanRosterMessageRenderer.cs | 35 +++++- .../State/ClanStateService.cs | 71 ++++++++++++ .../Registry/WorkspaceRegistry.cs | 53 +++++++-- src/RustPlusBot.Localization/Strings.fr.resx | 6 + src/RustPlusBot.Localization/Strings.resx | 6 + .../ClansRegistrationTests.cs | 27 +++++ .../Hosting/ClansHostedServiceTests.cs | 105 ++++++++++++++++++ .../Messages/ClanRendererTests.cs | 37 +++++- .../State/ClanStateServiceTests.cs | 80 ++++++++++++- .../CapabilityGatedChannelTests.cs | 10 +- .../Registry/WorkspaceRegistryTests.cs | 33 ++++++ .../WorkspaceRegistrationTests.cs | 19 ++++ .../StringsResourceParityTests.cs | 2 +- 15 files changed, 506 insertions(+), 31 deletions(-) create mode 100644 tests/RustPlusBot.Features.Clans.Tests/Hosting/ClansHostedServiceTests.cs diff --git a/src/RustPlusBot.Features.Clans/Hosting/ClansHostedService.cs b/src/RustPlusBot.Features.Clans/Hosting/ClansHostedService.cs index 0c198eaa..221f421b 100644 --- a/src/RustPlusBot.Features.Clans/Hosting/ClansHostedService.cs +++ b/src/RustPlusBot.Features.Clans/Hosting/ClansHostedService.cs @@ -58,7 +58,25 @@ private async Task ConsumeClanStateAsync(CancellationToken cancellationToken) await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) .ConfigureAwait(false)) { - await stateService.ApplyAsync(evt, cancellationToken).ConfigureAwait(false); + // Per-item isolation: a transient store failure, a Discord 5xx during the transition + // reconcile or an embed validation throw must cost one event, not the loop. Losing + // the loop would also strand the teardown path, leaving the clan channels behind + // for a player who has already left their clan. + try + { + await stateService.ApplyAsync(evt, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down: let the outer handler end the loop quietly. + throw; + } +#pragma warning disable CA1031 // Broad catch: one faulted clan state change must not end the loop. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogStateChangeFailed(logger, evt.GuildId, evt.ServerId, ex); + } } } catch (OperationCanceledException) @@ -75,4 +93,11 @@ private async Task ConsumeClanStateAsync(CancellationToken cancellationToken) [LoggerMessage(Level = LogLevel.Error, Message = "Clan state loop faulted.")] private static partial void LogStateLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, + Message = "Applying a clan state change for guild {GuildId} server {ServerId} failed.")] + private static partial void LogStateChangeFailed(ILogger logger, + ulong guildId, + Guid serverId, + Exception exception); } diff --git a/src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs b/src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs index 936f631d..f0d1991d 100644 --- a/src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs +++ b/src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs @@ -10,8 +10,8 @@ namespace RustPlusBot.Features.Clans.Messages; /// -/// Renders the anchored #claninfo pending-invites embed. Renders empty when there are no pending -/// invites so the reconciler never posts the message at all. +/// Renders the anchored #claninfo pending-invites embed. Renders empty only where there is no +/// clan to describe, so the reconciler never posts the message at all on a clanless server. /// /// Supplies the stored clan snapshot. /// Resolves invitee and recruiter Steam ids to display names. @@ -38,14 +38,20 @@ public async ValueTask RenderAsync(MessageRenderContext context, } var clan = await store.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); - if (clan is null || clan.Invites.Count == 0) + if (clan is null) { - // See ClanOverviewMessageRenderer: an empty payload keeps this key inert, both on clanless - // servers and when there is simply nothing pending. + // See ClanOverviewMessageRenderer: an empty payload keeps this key inert on clanless + // servers, where the channel this message would live in does not exist either. return new MessagePayload(null, null, null); } var culture = context.Culture; + if (clan.Invites.Count == 0) + { + // Honest over stale: an empty payload means "leave the previous message on screen", so + // returning one here would keep a since-resolved invite list pinned forever. + return new MessagePayload(null, EmptyState(culture), null); + } // One batched call covering both sides of every invite. var ids = new HashSet(); @@ -78,6 +84,16 @@ public async ValueTask RenderAsync(MessageRenderContext context, return new MessagePayload(null, embed, null); } + /// Builds the "nothing pending" embed shown while a clan is stored but has no invites. + /// The guild culture. + /// The empty-state embed. + private Embed EmptyState(string culture) => + new EmbedBuilder() + .WithTitle(localizer.Get("clan.invites.title", culture, "0")) + .WithColor(Color.Gold) + .WithDescription(localizer.Get("clan.invites.none", culture)) + .Build(); + private static string Name(IReadOnlyDictionary resolved, ulong steamId) => resolved.TryGetValue(steamId, out var name) ? name diff --git a/src/RustPlusBot.Features.Clans/Messages/ClanRosterMessageRenderer.cs b/src/RustPlusBot.Features.Clans/Messages/ClanRosterMessageRenderer.cs index 8310abf8..fdd2afb2 100644 --- a/src/RustPlusBot.Features.Clans/Messages/ClanRosterMessageRenderer.cs +++ b/src/RustPlusBot.Features.Clans/Messages/ClanRosterMessageRenderer.cs @@ -27,6 +27,13 @@ public sealed class ClanRosterMessageRenderer( /// Discord's hard cap on the length of an embed field value. private const int FieldValueLimit = 1024; + /// + /// Budget for the whole embed's text. Discord's hard cap is 6000 and + /// throws above it; the margin leaves room for the title and + /// the omission notice, both of which are added after the budget is spent. + /// + private const int EmbedTextBudget = 5200; + /// public string MessageKey => Key; @@ -66,6 +73,7 @@ public async ValueTask RenderAsync(MessageRenderContext context, .ConfigureAwait(false); var roles = clan.Roles.ToDictionary(r => r.RoleId); + var groups = new List<(string Name, string Value)>(); foreach (var role in clan.Roles.OrderBy(r => r.Rank).ThenBy(r => r.RoleId)) { var members = Ordered(clan.Members.Where(m => m.RoleId == role.RoleId)).ToList(); @@ -74,13 +82,36 @@ public async ValueTask RenderAsync(MessageRenderContext context, continue; } - embed.AddField(Header(role, culture), Body(members, resolved, culture)); + groups.Add((Header(role, culture), Body(members, resolved, culture))); } var orphans = Ordered(clan.Members.Where(m => !roles.ContainsKey(m.RoleId))).ToList(); if (orphans.Count > 0) { - embed.AddField(localizer.Get("clan.roster.unknownrole", culture), Body(orphans, resolved, culture)); + groups.Add((localizer.Get("clan.roster.unknownrole", culture), + Body(orphans, resolved, culture))); + } + + // Per-field truncation alone is not enough: enough role groups at their 1024-char ceiling + // breach Discord's 6000-char whole-embed cap, and Build() throws rather than trimming. + var spent = 0; + var added = 0; + foreach (var (name, value) in groups) + { + if (spent + name.Length + value.Length > EmbedTextBudget) + { + break; + } + + embed.AddField(name, value); + spent += name.Length + value.Length; + added++; + } + + if (added < groups.Count) + { + embed.WithDescription(localizer.Get("clan.roster.omitted", culture, + (groups.Count - added).ToString(CultureInfo.InvariantCulture))); } return new MessagePayload(null, embed.Build(), null); diff --git a/src/RustPlusBot.Features.Clans/State/ClanStateService.cs b/src/RustPlusBot.Features.Clans/State/ClanStateService.cs index 398fe6f4..87f81a18 100644 --- a/src/RustPlusBot.Features.Clans/State/ClanStateService.cs +++ b/src/RustPlusBot.Features.Clans/State/ClanStateService.cs @@ -119,6 +119,9 @@ private async Task ApplyHasClanAsync( var previous = await store.GetAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); await store.SaveAsync(evt.GuildId, evt.ServerId, snapshot, cancellationToken).ConfigureAwait(false); + // Harvest before rendering so the feed lines below can already use the learned names. + await RecordTeamNamesAsync(services, store, evt, snapshot, cancellationToken).ConfigureAwait(false); + var changes = ClanSnapshotDiffer.Diff(previous, snapshot); await PostChangesAsync(services, evt, changes, snapshot, cancellationToken).ConfigureAwait(false); @@ -131,6 +134,67 @@ private async Task ApplyHasClanAsync( } } + /// + /// Populates the SteamId → Name cache from the live team snapshot, the second of the two name + /// sources (clan chat senders being the first). Without it a fresh install renders every roster + /// entry as a bare profile link until that member happens to speak in clan chat. + /// + /// The per-call scope's services. + /// The scoped clan store. + /// The change being applied (identifies the server). + /// The clan snapshot whose roster bounds what is worth recording. + /// A cancellation token. + /// A task that completes once any learned names have been recorded. + private async Task RecordTeamNamesAsync( + IServiceProvider services, + IClanStore store, + ClanStateChangedEvent evt, + ClanSnapshot snapshot, + CancellationToken cancellationToken) + { + try + { + var query = services.GetRequiredService(); + var team = await query.GetTeamInfoAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (team is null) + { + // Disconnected: names stay as they are, which is exactly the pre-existing behaviour. + return; + } + + var roster = snapshot.Members.Select(m => m.SteamId).ToHashSet(); + var candidates = team.Members + .Where(m => roster.Contains(m.SteamId) && !string.IsNullOrWhiteSpace(m.Name)) + .ToList(); + if (candidates.Count == 0) + { + return; + } + + var known = await store + .GetNamesAsync(evt.GuildId, evt.ServerId, candidates.ConvertAll(m => m.SteamId), cancellationToken) + .ConfigureAwait(false); + + foreach (var member in candidates.Where(m => !known.ContainsKey(m.SteamId))) + { + await store.RecordNameAsync(evt.GuildId, evt.ServerId, member.SteamId, member.Name, + cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down: the caller's own awaits will observe this too. + throw; + } +#pragma warning disable CA1031 // Broad catch: name harvesting is cosmetic and must never block persistence. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogNameHarvestFailed(logger, evt.GuildId, evt.ServerId, ex); + } + } + private async Task ReconcileAsync( IServiceProvider services, ClanStateChangedEvent evt, @@ -240,4 +304,11 @@ private List ApplyScoreThrottle(ClanStateChangedEvent evt, IReadOnly [LoggerMessage(Level = LogLevel.Warning, Message = "A HasClan clan state change for guild {GuildId} server {ServerId} carried no snapshot.")] private static partial void LogMissingSnapshot(ILogger logger, ulong guildId, Guid serverId); + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Harvesting clan member names from the team snapshot for guild {GuildId} server {ServerId} failed.")] + private static partial void LogNameHarvestFailed(ILogger logger, + ulong guildId, + Guid serverId, + Exception exception); } diff --git a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs index 55ed7189..4d95382e 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs @@ -1,19 +1,50 @@ namespace RustPlusBot.Features.Workspace.Registry; /// Aggregates contributed spec providers into ordered, scope-filtered views. -/// All channel spec providers. -/// All message spec providers. -/// All capability providers, indexed by capability name. -internal sealed class WorkspaceRegistry( - IEnumerable channelProviders, - IEnumerable messageProviders, - IEnumerable capabilityProviders) : IWorkspaceRegistry +internal sealed class WorkspaceRegistry : IWorkspaceRegistry { - private readonly Dictionary _capabilities = - capabilityProviders.ToDictionary(p => p.Capability, StringComparer.Ordinal); + private readonly Dictionary _capabilities; + private readonly List _channels; + private readonly List _messages; - private readonly List _channels = [.. channelProviders.SelectMany(p => p.GetChannelSpecs())]; - private readonly List _messages = [.. messageProviders.SelectMany(p => p.GetMessageSpecs())]; + /// Initializes the registry and validates that every gated channel has a provider. + /// All channel spec providers. + /// All message spec providers. + /// All capability providers, indexed by capability name. + /// + /// A channel spec names a capability that no registered provider answers for. + /// + public WorkspaceRegistry( + IEnumerable channelProviders, + IEnumerable messageProviders, + IEnumerable capabilityProviders) + { + _capabilities = capabilityProviders.ToDictionary(p => p.Capability, StringComparer.Ordinal); + _channels = [.. channelProviders.SelectMany(p => p.GetChannelSpecs())]; + _messages = [.. messageProviders.SelectMany(p => p.GetMessageSpecs())]; + + // Fail the host rather than the guild's data: the reconciler reads "no provider" as "capability + // unavailable", and unavailable means it DELETES the gated channel and everything in it. A host + // that composes AddWorkspace() without the module owning a capability would silently destroy + // those channels on its first heal, so refuse to start instead. + var missing = new SortedSet(StringComparer.Ordinal); + foreach (var spec in _channels) + { + if (spec.Capability is { } capability && !_capabilities.ContainsKey(capability)) + { + missing.Add(capability); + } + } + + if (missing.Count > 0) + { + throw new InvalidOperationException( + "No IWorkspaceCapabilityProvider is registered for workspace channel capability(ies): " + + string.Join(", ", missing) + + ". The host must compose the feature module that provides them; without it the reconciler " + + "would treat the gated channels as unavailable and delete them along with their history."); + } + } /// public ValueTask IsCapabilityAvailableAsync(string capability, diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index f190a113..ab319d1e 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -162,6 +162,9 @@ {0} — invité par {1} · {2} + + Aucune invitation en attente. + 📨 Invitations en attente ({0}) @@ -219,6 +222,9 @@ · {0} + + …et {0} groupe(s) de rôles omis pour respecter la limite de taille des embeds Discord + Voir les journaux diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 4addd7a4..cae8bae4 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -162,6 +162,9 @@ {0} — invited by {1} · {2} + + No pending invites. + 📨 Pending invites ({0}) @@ -219,6 +222,9 @@ · {0} + + …and {0} more role group(s) omitted to fit Discord's embed size limit + View logs diff --git a/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs b/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs index 0da450cb..6220d4da 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs @@ -13,9 +13,11 @@ using RustPlusBot.Features.Clans.Posting; using RustPlusBot.Features.Clans.State; using RustPlusBot.Features.Clans.Writing; +using RustPlusBot.Features.Workspace; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Persistence; using RustPlusBot.Persistence.Clans; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Workspace; @@ -81,6 +83,31 @@ public async Task Registers_the_assembly_that_carries_the_clan_interaction_modul a => a.Assembly == typeof(ClanMotdModule).Assembly); } + [Fact] + public void The_composed_host_registry_covers_every_gated_channel_capability() + { + // WorkspaceRegistry refuses to build when a ChannelSpec names a capability no provider + // answers for, because the reconciler would otherwise delete the gated channels. The clan + // specs live in Features.Workspace and the provider in Features.Clans, so this pins that the + // real composition of the two satisfies the guard. + var services = new ServiceCollection(); + services.AddSingleton(new DiscordSocketClient()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(Substitute.For()); + services.AddLogging(); + services.AddBotPersistence("DataSource=:memory:"); + services.AddWorkspace(); + services.AddClans(); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + + Assert.NotNull(provider.GetRequiredService()); + } + [Fact] public void Does_not_register_a_second_chat_bridge() { diff --git a/tests/RustPlusBot.Features.Clans.Tests/Hosting/ClansHostedServiceTests.cs b/tests/RustPlusBot.Features.Clans.Tests/Hosting/ClansHostedServiceTests.cs new file mode 100644 index 00000000..10e6013f --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/Hosting/ClansHostedServiceTests.cs @@ -0,0 +1,105 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Clans.Hosting; +using RustPlusBot.Features.Clans.Messages; +using RustPlusBot.Features.Clans.Names; +using RustPlusBot.Features.Clans.Posting; +using RustPlusBot.Features.Clans.State; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Clans; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Clans.Tests.Hosting; + +public sealed class ClansHostedServiceTests +{ + private const ulong Guild = 42UL; + + private static readonly Guid Poison = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + private static readonly Guid Healthy = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + [Fact] + public async Task A_faulted_state_change_does_not_end_the_state_loop() + { + // The loop is the only thing that ever tears the clan channels down again, so one bad event + // ending it would leave a player who has left their clan with the channels forever. + var store = Substitute.For(); + store.GetAsync(Guild, Poison, Arg.Any()) + .Returns(_ => throw new InvalidOperationException("transient store failure")); + store.GetAsync(Guild, Healthy, Arg.Any()).Returns((ClanSnapshot?)null); + + var applied = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + store.When(s => s.SaveAsync(Guild, Healthy, Arg.Any(), Arg.Any())) + .Do(_ => applied.TrySetResult()); + + var bus = new InMemoryEventBus(); + var service = new ClansHostedService(bus, BuildStateService(store), + NullLogger.Instance); + await service.StartAsync(CancellationToken.None); + + try + { + // Republished until observed: the subscription is established asynchronously by StartAsync. + // The poison event is always enqueued first, so the healthy one can only be applied by a + // loop that survived it. + var deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (!applied.Task.IsCompleted && DateTimeOffset.UtcNow < deadline) + { + await bus.PublishAsync( + new ClanStateChangedEvent(Guild, Poison, ClanProbeStatus.HasClan, Snapshot())); + await bus.PublishAsync( + new ClanStateChangedEvent(Guild, Healthy, ClanProbeStatus.HasClan, Snapshot())); + await Task.Delay(20); + } + + Assert.True(applied.Task.IsCompleted, + "The healthy clan state change was never applied: the loop died on the failing one."); + } + finally + { + await service.StopAsync(CancellationToken.None); + service.Dispose(); + } + } + + private static ClanSnapshot Snapshot() => + new(7L, "Wolves", DateTimeOffset.UnixEpoch, 1UL, null, null, null, null, null, null, null, [], [], []); + + private static ClanStateService BuildStateService(IClanStore store) + { + var services = new ServiceCollection(); + services.AddScoped(_ => store); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + + var scopeFactory = provider.GetRequiredService(); + var clock = new FixedClock(); + + return new ClanStateService( + scopeFactory, + Substitute.For(), + Substitute.For(), + new ClanChangeRenderer(Substitute.For()), + new ClanCapabilityProvider(scopeFactory, clock), + clock, + NullLogger.Instance); + } + + private sealed class FixedClock : IClock + { + public DateTimeOffset UtcNow => DateTimeOffset.UnixEpoch; + } +} diff --git a/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs b/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs index 1d3b44e5..c1cf2f21 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs @@ -268,6 +268,33 @@ public async Task Roster_truncates_a_role_body_that_would_exceed_the_field_value Assert.Equal(expectedOmitted.ToString(CultureInfo.InvariantCulture), omittedToken); } + [Fact] + public async Task Roster_stops_adding_role_groups_before_the_whole_embed_cap() + { + // Per-field truncation caps each group at 1024; enough groups still breach Discord's 6000 + // whole-embed cap, and EmbedBuilder.Build() throws rather than trimming. + var roles = new List(); + var members = new List(); + for (var roleId = 1; roleId <= 8; roleId++) + { + roles.Add(Role(roleId, roleId, $"Role{roleId.ToString(CultureInfo.InvariantCulture)}")); + for (var i = 0; i < 60; i++) + { + members.Add(Member((ulong)((roleId * 1000) + i), roleId, joined: DateTimeOffset.UnixEpoch)); + } + } + + var renderer = new ClanRosterMessageRenderer(Store(Clan(roles: roles, members: members)), Resolver(), + Localizer()); + + var payload = await renderer.RenderAsync(Context, CancellationToken.None); + + Assert.NotNull(payload.Embed); + Assert.True(payload.Embed.Length <= 6000, $"Embed length {payload.Embed.Length} exceeds Discord's cap."); + Assert.True(payload.Embed.Fields.Length < roles.Count, "No role group was omitted."); + Assert.Contains("clan.roster.omitted", payload.Embed.Description, StringComparison.Ordinal); + } + [Fact] public async Task Roster_puts_online_members_first_within_a_role() { @@ -386,15 +413,17 @@ public async Task Invites_returns_an_empty_payload_when_no_clan_is_stored() } [Fact] - public async Task Invites_returns_an_empty_payload_when_there_are_none() + public async Task Invites_renders_an_explicit_empty_state_when_a_clan_has_none() { + // An empty payload means "leave the previous message on screen" to both the reconciler and + // the refresher, so an empty one here would pin a resolved invite list forever. var renderer = new ClanInvitesMessageRenderer(Store(Clan()), Resolver(), Localizer()); var payload = await renderer.RenderAsync(Context, CancellationToken.None); - Assert.Null(payload.Text); - Assert.Null(payload.Embed); - Assert.Null(payload.Components); + Assert.NotNull(payload.Embed); + Assert.Contains("clan.invites.none", payload.Embed.Description, StringComparison.Ordinal); + Assert.Contains("clan.invites.title 0", payload.Embed.Title, StringComparison.Ordinal); } [Fact] diff --git a/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs index 531d139d..c7aa80f2 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs @@ -25,6 +25,8 @@ public sealed class ClanStateServiceTests private readonly IClanInfoChannelLocator _locator = Substitute.For(); private readonly IClanNameResolver _names = Substitute.For(); private readonly IClanFeedPoster _poster = Substitute.For(); + + private readonly IRustServerQuery _query = Substitute.For(); private readonly IWorkspaceReconciler _reconciler = Substitute.For(); private readonly IClanStore _store = Substitute.For(); @@ -244,6 +246,70 @@ await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatu await _store.Received(1).SaveAsync(Guild, Server, renamed, Arg.Any()); } + [Fact] + public async Task Records_names_from_the_team_snapshot_for_clan_members_we_do_not_know() + { + // The second of the design's two name sources. Without it a fresh install renders every + // roster entry as a bare profile link until that member happens to speak in clan chat. + _store.GetAsync(Guild, Server, Arg.Any()).Returns((ClanSnapshot?)null); + _store.GetNamesAsync(Guild, Server, Arg.Any>(), Arg.Any()) + .Returns(new Dictionary + { + [222UL] = "Bob" + }); + _query.GetTeamInfoAsync(Guild, Server, Arg.Any()) + .Returns(new TeamInfoSnapshot(111UL, + [TeamMember(111UL, "Alice"), TeamMember(222UL, "Bob"), TeamMember(333UL, "Stranger")])); + var service = Build(); + + await service.ApplyAsync( + new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, + Snapshot(members: [Member(111UL), Member(222UL)])), + CancellationToken.None); + + await _store.Received(1).RecordNameAsync(Guild, Server, 111UL, "Alice", Arg.Any()); + + // 222 is already cached, and 333 is on the team but not in the clan. + await _store.DidNotReceive() + .RecordNameAsync(Guild, Server, 222UL, Arg.Any(), Arg.Any()); + await _store.DidNotReceive() + .RecordNameAsync(Guild, Server, 333UL, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task A_null_team_snapshot_records_nothing_and_changes_nothing_else() + { + _query.GetTeamInfoAsync(Guild, Server, Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot()); + var renamed = Snapshot("Bears"); + var service = Build(); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, renamed), + CancellationToken.None); + + await _store.DidNotReceive().RecordNameAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + await _store.Received(1).SaveAsync(Guild, Server, renamed, Arg.Any()); + await _poster.Received(1).PostAsync(Channel, "clan.event.renamed", Arg.Any()); + } + + [Fact] + public async Task A_failing_team_query_still_persists_the_clan_and_posts_the_feed() + { + _query.GetTeamInfoAsync(Guild, Server, Arg.Any()) + .Returns(_ => throw new InvalidOperationException("socket gone")); + _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot()); + var renamed = Snapshot("Bears"); + var service = Build(); + + await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, renamed), + CancellationToken.None); + + await _store.Received(1).SaveAsync(Guild, Server, renamed, Arg.Any()); + await _poster.Received(1).PostAsync(Channel, "clan.event.renamed", Arg.Any()); + } + [Fact] public async Task Score_changes_are_throttled_to_one_post_per_minute() { @@ -271,8 +337,17 @@ await service.ApplyAsync( await _poster.Received(2).PostAsync(Channel, "clan.event.score", Arg.Any()); } - private static ClanSnapshot Snapshot(string name = "Wolves", long? score = null) => - new(7L, name, DateTimeOffset.UnixEpoch, 1UL, null, null, null, null, null, null, score, [], [], []); + private static ClanSnapshot Snapshot( + string name = "Wolves", + long? score = null, + IReadOnlyList? members = null) => + new(7L, name, DateTimeOffset.UnixEpoch, 1UL, null, null, null, null, null, null, score, [], members ?? [], []); + + private static ClanMemberSnapshot Member(ulong steamId) => + new(steamId, 1, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, null, true); + + private static TeamMemberSnapshot TeamMember(ulong steamId, string name) => + new(steamId, name, 0f, 0f, true, true, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch); private ClanStateService Build(IClock? clock = null) { @@ -281,6 +356,7 @@ private ClanStateService Build(IClock? clock = null) services.AddScoped(_ => _workspace); services.AddScoped(_ => _names); services.AddScoped(_ => _reconciler); + services.AddScoped(_ => _query); var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true diff --git a/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs index 36521e76..30c3cc02 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs @@ -58,17 +58,17 @@ public async Task Does_not_create_a_capability_gated_channel_that_was_never_prov } [Fact] - public async Task Treats_a_capability_with_no_registered_provider_as_unavailable() + public void Fails_fast_when_a_gated_channel_has_no_registered_capability_provider() { + // "No provider" reads as "unavailable", and unavailable means the reconciler deletes the + // channel and its history. A misconfigured host must fail to start, not destroy data. var harness = NewHarness() .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") .WithChannel(WorkspaceScope.PerServer, "ghostly", "channel.teamchat.name", 1, "ghost"); - var sut = harness.Build(); - await sut.ReconcileServerAsync(1, ServerId); + var ex = Assert.Throws(harness.Build); - Assert.DoesNotContain(await harness.Store.GetChannelsAsync(1, ServerId), c => c.ChannelKey == "ghostly"); - Assert.Equal(1, harness.Gateway.CreatedChannels); + Assert.Contains("ghost", ex.Message, StringComparison.Ordinal); } [Fact] diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs index 2ade435c..063a77aa 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs @@ -16,6 +16,39 @@ public void ChannelSpecs_AggregateAcrossProviders_OrderedByOrder() Assert.Equal(["info"], perServer.Select(s => s.Key)); } + [Fact] + public void Throws_when_a_channel_spec_names_a_capability_with_no_provider() + { + var ex = Assert.Throws(() => + new WorkspaceRegistry([new GatedProvider()], [], [])); + + Assert.Contains("ghost", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Accepts_a_gated_channel_whose_capability_provider_is_registered() + { + var registry = new WorkspaceRegistry([new GatedProvider()], [], [new StubCapability("ghost")]); + + Assert.Equal(["gated"], registry.GetChannelSpecs(WorkspaceScope.PerServer).Select(s => s.Key)); + } + + private sealed class GatedProvider : IChannelSpecProvider + { + public IEnumerable GetChannelSpecs() => + [ + new(WorkspaceScope.PerServer, "gated", "gated.name", ChannelPermissionProfile.ReadOnly, 0, "ghost"), + ]; + } + + private sealed class StubCapability(string capability) : IWorkspaceCapabilityProvider + { + public string Capability { get; } = capability; + + public ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken) => + ValueTask.FromResult(true); + } + private sealed class ProviderA : IChannelSpecProvider { public IEnumerable GetChannelSpecs() => diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs index 3d83fd8b..e280deea 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs @@ -7,6 +7,7 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Features.Workspace.Teardown; using RustPlusBot.Persistence; @@ -26,6 +27,11 @@ public void ReconcilerAndTeardown_ResolveFromScope() services.AddBotPersistence("DataSource=:memory:"); services.AddWorkspace(); + // AddWorkspace() contributes the clan-gated channel specs but not the provider that answers + // for them; the registry now refuses to build without it. Features.Clans supplies the real + // one in the host — see WorkspaceRegistryTests for the guard itself. + services.AddSingleton(new StubClanCapability()); + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true @@ -64,6 +70,11 @@ public void AddWorkspace_registers_one_chat_channel_locator_per_kind_with_matchi services.AddBotPersistence("DataSource=:memory:"); services.AddWorkspace(); + // AddWorkspace() contributes the clan-gated channel specs but not the provider that answers + // for them; the registry now refuses to build without it. Features.Clans supplies the real + // one in the host — see WorkspaceRegistryTests for the guard itself. + services.AddSingleton(new StubClanCapability()); + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true @@ -80,4 +91,12 @@ public void AddWorkspace_registers_one_chat_channel_locator_per_kind_with_matchi var clanLocator = Assert.Single(locators, l => l.Kind == ChatChannelKind.Clan); Assert.IsType(clanLocator); } + + private sealed class StubClanCapability : IWorkspaceCapabilityProvider + { + public string Capability => WorkspaceCapabilities.Clan; + + public ValueTask IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken) => + ValueTask.FromResult(false); + } } diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index ae64f24a..5d47f2f0 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(363, EnglishKeys().Count); + Assert.Equal(365, EnglishKeys().Count); } } From 359ccfb5e8e45c2e9b95d3a31cbe4662ee45d19d Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 13:16:37 +0200 Subject: [PATCH 34/39] Correct three clan comments that describe code that does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClanCapabilityProvider's CacheTtl doc justified its value with "the heal path reconciles every server on a timer". WorkspaceHostedService heals at startup and on ChannelDestroyed only; there is no timer. Say what actually bounds the staleness: reconciles are event-driven and Invalidate, not expiry, is what guarantees a transition is seen. ClanMessageKeyParityTests claimed Features.Clans cannot see the internal WorkspaceMessageKeys. It can — Features.Workspace grants it InternalsVisibleTo. The constants are independent by choice, which is what makes the assertion meaningful. ClanMotdWriter claimed to re-check the permission because roles can change between render and click. It reads the same cached snapshot the button was rendered from, so a demotion the poller has not observed yet still gets through. Describe what it does defend against: a forged component id and a button clicked from scrollback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../State/ClanCapabilityProvider.cs | 8 +++++--- .../Writing/ClanMotdWriter.cs | 5 ++++- .../Messages/ClanMessageKeyParityTests.cs | 10 ++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs b/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs index c286234c..e46dfa42 100644 --- a/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs +++ b/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs @@ -17,9 +17,11 @@ internal sealed class ClanCapabilityProvider(IServiceScopeFactory scopeFactory, : IWorkspaceCapabilityProvider { /// - /// How long an answer is reused. Two gated specs mean two probes per server per reconcile, and - /// the heal path reconciles every server on a timer; this is kept well under the reconcile - /// interval so a clan transition is still picked up promptly. + /// How long an answer is reused. Two gated specs mean two probes per server per reconcile, so a + /// short reuse window collapses them into one store read. It stays short because reconciles are + /// event-driven (startup, channel deletion, server registration, clan transition) rather than + /// timed: a cached answer must never outlive the transition that prompted the reconcile, and + /// — not expiry — is what guarantees a transition is seen. /// private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(5); diff --git a/src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs b/src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs index 303e2548..dc6b2656 100644 --- a/src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs +++ b/src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs @@ -6,7 +6,10 @@ namespace RustPlusBot.Features.Clans.Writing; /// /// Applies a clan MOTD change, enforcing the acting player's clan permission before touching the /// socket. The permission check is duplicated here rather than trusted from the button's presence: -/// component payloads can be forged, and roles can change between render and click. +/// a component id can be forged, and a button rendered before a demotion can still be clicked from +/// scrollback afterwards. It is not a fresh authority check — it reads the same cached clan +/// snapshot the button was rendered from, so a demotion the poller has not yet observed still gets +/// through; the game's own API is the last word there. /// /// Supplies the stored clan snapshot for the permission check. /// Performs the write on the live socket. diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/ClanMessageKeyParityTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ClanMessageKeyParityTests.cs index 0daaab86..bddd4a01 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/ClanMessageKeyParityTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ClanMessageKeyParityTests.cs @@ -3,10 +3,12 @@ namespace RustPlusBot.Features.Workspace.Tests.Messages; /// -/// Pins that the clan renderers' key literals (declared in Features.Clans, which cannot see -/// Features.Workspace's internal ) still match the keys the -/// reconciler looks renderers up by. The reconciler compares with StringComparer.Ordinal, -/// so a silent rename on either side would unhook a renderer without any other test catching it. +/// Pins that the clan renderers' key literals still match the keys the reconciler looks renderers +/// up by. Features.Clans can see Features.Workspace's internal +/// (it is granted InternalsVisibleTo) but deliberately declares its own public constants +/// instead, so the two sides are independent literals. The reconciler compares them with +/// StringComparer.Ordinal, so a silent rename on either side would unhook a renderer +/// without any other test catching it. /// public sealed class ClanMessageKeyParityTests { From c906a92bc15247039030eb3b332a56678c06d7ef Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 13:51:00 +0200 Subject: [PATCH 35/39] Make the workspace registry guard actually fail startup fast WorkspaceHostedService.StartAsync now resolves IWorkspaceRegistry itself, outside any try/catch, before scheduling any heal work. Previously the registry's constructor guard against an unprovided channel capability was only ever triggered lazily from inside broad catches (the startup heal and each event-loop consumer), so a host composing AddWorkspace() without the module owning a capability would start cleanly and only fault quietly on the first reconcile, contradicting the guard's own comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Hosting/WorkspaceHostedService.cs | 12 +++++ .../Hosting/WorkspaceConnectionStatusTests.cs | 7 +++ ...WorkspaceHostedServiceStartupGuardTests.cs | 46 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceHostedServiceStartupGuardTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs index a1dfd29e..e458b1ee 100644 --- a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs +++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Workspace.Hosting; @@ -32,6 +33,17 @@ internal sealed class WorkspaceHostedService( /// public Task StartAsync(CancellationToken cancellationToken) { + // Force the workspace registry's construction now, synchronously, before any heal work is + // queued. Its constructor throws when a channel spec names a capability with no registered + // provider. Every other place below resolves it lazily inside a broad catch, so a misconfigured + // host would otherwise start cleanly and only fault quietly on the first reconcile. Resolving it + // here, outside any try or catch, lets that exception propagate out of this method so the host + // genuinely fails to start instead. + using (var scope = scopeFactory.CreateScope()) + { + scope.ServiceProvider.GetRequiredService(); + } + client.Ready += OnReadyAsync; client.ChannelDestroyed += OnChannelDestroyedAsync; _serverRegisteredLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs index fa30d0c6..5fc46234 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs @@ -5,6 +5,7 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Workspace.Hosting; using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; namespace RustPlusBot.Features.Workspace.Tests.Hosting; @@ -16,6 +17,9 @@ public async Task ConnectionStatusChanged_ReconcilesThatServer() var reconciler = Substitute.For(); var services = new ServiceCollection(); services.AddScoped(_ => reconciler); + // StartAsync now resolves IWorkspaceRegistry up front (see the startup guard test); this + // minimal container needs a stand-in so that resolution succeeds. + services.AddSingleton(Substitute.For()); await using var provider = services.BuildServiceProvider(); var bus = new InMemoryEventBus(); @@ -48,6 +52,9 @@ public async Task ServerCredentialsChanged_ReconcilesThatServer() var reconciler = Substitute.For(); var services = new ServiceCollection(); services.AddScoped(_ => reconciler); + // StartAsync now resolves IWorkspaceRegistry up front (see the startup guard test); this + // minimal container needs a stand-in so that resolution succeeds. + services.AddSingleton(Substitute.For()); await using var provider = services.BuildServiceProvider(); var bus = new InMemoryEventBus(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceHostedServiceStartupGuardTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceHostedServiceStartupGuardTests.cs new file mode 100644 index 00000000..6a3ac76a --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceHostedServiceStartupGuardTests.cs @@ -0,0 +1,46 @@ +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Workspace.Hosting; +using RustPlusBot.Persistence; + +namespace RustPlusBot.Features.Workspace.Tests.Hosting; + +public sealed class WorkspaceHostedServiceStartupGuardTests +{ + [Fact] + public async Task StartAsync_throws_when_a_gated_capability_has_no_provider() + { + // Mirrors a host that composes AddWorkspace() without the module (e.g. AddClans()) that owns + // the "clan" capability. WorkspaceRegistry's constructor guards against this because the + // reconciler would otherwise treat the gated channels as unavailable and delete them; this test + // proves the host now fails fast at StartAsync instead of starting and faulting quietly later. + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(Substitute.For()); + services.AddLogging(); + services.AddBotPersistence("DataSource=:memory:"); + services.AddWorkspace(); + // Deliberately no IWorkspaceCapabilityProvider registered for "clan". + + await using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + + var client = new DiscordSocketClient(); + var hostedService = new WorkspaceHostedService( + client, + provider.GetRequiredService(), + provider.GetRequiredService(), + NullLogger.Instance); + + var ex = await Assert.ThrowsAsync(() => hostedService.StartAsync(default)); + Assert.Contains("clan", ex.Message, StringComparison.OrdinalIgnoreCase); + } +} From d5ef79f8f3ace6263622b7308aff53ed96d010fe Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 13:51:09 +0200 Subject: [PATCH 36/39] Skip the team-info socket call when every clan member's name is cached ClanStateService.RecordTeamNamesAsync now checks IClanStore.GetNamesAsync against the full roster first and only calls IRustServerQuery.GetTeamInfoAsync when a roster member's name is still unknown. OnClanChanged fires on every clan edit, including score changes, and score moves on every kill, so the previous unconditional call issued a companion-API RPC per kill per server. In steady state this now costs zero extra RPCs; the socket is only touched when a genuinely unknown member appears. Best-effort semantics are unchanged: a null snapshot, a failing query, or an unknown name still never blocks persistence or the feed post. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../State/ClanStateService.cs | 23 ++++++----- .../State/ClanStateServiceTests.cs | 41 +++++++++++++++++-- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/RustPlusBot.Features.Clans/State/ClanStateService.cs b/src/RustPlusBot.Features.Clans/State/ClanStateService.cs index 87f81a18..a7627969 100644 --- a/src/RustPlusBot.Features.Clans/State/ClanStateService.cs +++ b/src/RustPlusBot.Features.Clans/State/ClanStateService.cs @@ -154,6 +154,17 @@ private async Task RecordTeamNamesAsync( { try { + var roster = snapshot.Members.Select(m => m.SteamId).ToHashSet(); + var known = await store.GetNamesAsync(evt.GuildId, evt.ServerId, roster, cancellationToken) + .ConfigureAwait(false); + if (roster.All(known.ContainsKey)) + { + // Every roster member already has a cached name: skip the companion-API round trip + // entirely. OnClanChanged fires on any clan edit, including score changes, and score + // moves on every kill — without this check that would be one RPC per kill per server. + return; + } + var query = services.GetRequiredService(); var team = await query.GetTeamInfoAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); @@ -163,18 +174,8 @@ private async Task RecordTeamNamesAsync( return; } - var roster = snapshot.Members.Select(m => m.SteamId).ToHashSet(); var candidates = team.Members - .Where(m => roster.Contains(m.SteamId) && !string.IsNullOrWhiteSpace(m.Name)) - .ToList(); - if (candidates.Count == 0) - { - return; - } - - var known = await store - .GetNamesAsync(evt.GuildId, evt.ServerId, candidates.ConvertAll(m => m.SteamId), cancellationToken) - .ConfigureAwait(false); + .Where(m => roster.Contains(m.SteamId) && !string.IsNullOrWhiteSpace(m.Name)); foreach (var member in candidates.Where(m => !known.ContainsKey(m.SteamId))) { diff --git a/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs index c7aa80f2..0da73a25 100644 --- a/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs @@ -267,6 +267,8 @@ await service.ApplyAsync( Snapshot(members: [Member(111UL), Member(222UL)])), CancellationToken.None); + // 111 is missing from the cache, so the socket call must happen and its answer recorded. + await _query.Received(1).GetTeamInfoAsync(Guild, Server, Arg.Any()); await _store.Received(1).RecordNameAsync(Guild, Server, 111UL, "Alice", Arg.Any()); // 222 is already cached, and 333 is on the team but not in the clan. @@ -276,18 +278,46 @@ await _store.DidNotReceive() .RecordNameAsync(Guild, Server, 333UL, Arg.Any(), Arg.Any()); } + [Fact] + public async Task Skips_the_team_query_when_every_roster_member_already_has_a_cached_name() + { + // OnClanChanged fires on any clan edit, including score changes, and score moves on every + // kill: once the whole roster's names are cached this must cost zero companion-API RPCs. + _store.GetAsync(Guild, Server, Arg.Any()).Returns((ClanSnapshot?)null); + _store.GetNamesAsync(Guild, Server, Arg.Any>(), Arg.Any()) + .Returns(new Dictionary + { + [111UL] = "Alice", [222UL] = "Bob" + }); + var service = Build(); + + await service.ApplyAsync( + new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, + Snapshot(members: [Member(111UL), Member(222UL)])), + CancellationToken.None); + + await _query.DidNotReceive() + .GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _store.DidNotReceive().RecordNameAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + [Fact] public async Task A_null_team_snapshot_records_nothing_and_changes_nothing_else() { _query.GetTeamInfoAsync(Guild, Server, Arg.Any()) .Returns((TeamInfoSnapshot?)null); - _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot()); - var renamed = Snapshot("Bears"); + // A non-empty, uncached roster so the harvest actually reaches the socket call whose + // null answer this test is meant to prove is harmless. + _store.GetAsync(Guild, Server, Arg.Any()) + .Returns(Snapshot(members: [Member(111UL)])); + var renamed = Snapshot("Bears", members: [Member(111UL)]); var service = Build(); await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, renamed), CancellationToken.None); + await _query.Received(1).GetTeamInfoAsync(Guild, Server, Arg.Any()); await _store.DidNotReceive().RecordNameAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await _store.Received(1).SaveAsync(Guild, Server, renamed, Arg.Any()); @@ -299,13 +329,16 @@ public async Task A_failing_team_query_still_persists_the_clan_and_posts_the_fee { _query.GetTeamInfoAsync(Guild, Server, Arg.Any()) .Returns(_ => throw new InvalidOperationException("socket gone")); - _store.GetAsync(Guild, Server, Arg.Any()).Returns(Snapshot()); - var renamed = Snapshot("Bears"); + // A non-empty, uncached roster so the harvest actually reaches the socket call that throws. + _store.GetAsync(Guild, Server, Arg.Any()) + .Returns(Snapshot(members: [Member(111UL)])); + var renamed = Snapshot("Bears", members: [Member(111UL)]); var service = Build(); await service.ApplyAsync(new ClanStateChangedEvent(Guild, Server, ClanProbeStatus.HasClan, renamed), CancellationToken.None); + await _query.Received(1).GetTeamInfoAsync(Guild, Server, Arg.Any()); await _store.Received(1).SaveAsync(Guild, Server, renamed, Arg.Any()); await _poster.Received(1).PostAsync(Channel, "clan.event.renamed", Arg.Any()); } From 1efe1389b9f90c674f9ff69925df5a6789df5829 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 22 Jul 2026 14:17:08 +0200 Subject: [PATCH 37/39] Filter clan name lookups by GuildId and self-heal stale GuildId on every clan write GetNamesAsync now filters on GuildId like the sibling stores, matching HasClanAsync/GetAsync convention. RecordNameAsync and SaveAsync keep their lookups keyed on the true primary key (ServerId[+SteamId], not GuildId) to avoid a duplicate-key crash if GuildId is ever stale, but now assign GuildId on every write (insert and update) so any stale value self-heals instead of staying permanently invisible to the newly filtered reads. SaveAsync had the same latent lookup bug as RecordNameAsync had before an earlier fix; a test reproduces the pre-fix InvalidOperationException before applying the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Clans/ClanStore.cs | 12 ++-- .../ClanStoreTests.cs | 71 +++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/RustPlusBot.Persistence/Clans/ClanStore.cs b/src/RustPlusBot.Persistence/Clans/ClanStore.cs index c8f54b0f..b32c9500 100644 --- a/src/RustPlusBot.Persistence/Clans/ClanStore.cs +++ b/src/RustPlusBot.Persistence/Clans/ClanStore.cs @@ -53,18 +53,19 @@ public async Task SaveAsync( ArgumentNullException.ThrowIfNull(snapshot); var row = await db.ClanStates - .FirstOrDefaultAsync(s => s.ServerId == serverId && s.GuildId == guildId, cancellationToken) + .FirstOrDefaultAsync(s => s.ServerId == serverId, cancellationToken) .ConfigureAwait(false); if (row is null) { row = new ClanState { - GuildId = guildId, ServerId = serverId + ServerId = serverId }; db.ClanStates.Add(row); } + row.GuildId = guildId; row.ClanId = snapshot.ClanId; row.Name = snapshot.Name; row.Created = snapshot.Created; @@ -127,7 +128,7 @@ public async Task> GetNamesAsync( return await db.ClanPlayerNames .AsNoTracking() - .Where(n => n.ServerId == serverId && steamIds.Contains(n.SteamId)) + .Where(n => n.GuildId == guildId && n.ServerId == serverId && steamIds.Contains(n.SteamId)) .ToDictionaryAsync(n => n.SteamId, n => n.Name, cancellationToken) .ConfigureAwait(false); } @@ -148,7 +149,7 @@ public async Task RecordNameAsync( cancellationToken) .ConfigureAwait(false); - if (row is not null && string.Equals(row.Name, name, StringComparison.Ordinal)) + if (row is not null && row.GuildId == guildId && string.Equals(row.Name, name, StringComparison.Ordinal)) { return; } @@ -157,11 +158,12 @@ public async Task RecordNameAsync( { row = new ClanPlayerName { - GuildId = guildId, ServerId = serverId, SteamId = steamId + ServerId = serverId, SteamId = steamId }; db.ClanPlayerNames.Add(row); } + row.GuildId = guildId; row.Name = name; row.UpdatedUtc = clock.UtcNow; diff --git a/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs index d44157f4..03393681 100644 --- a/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs @@ -3,6 +3,7 @@ using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Clans; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.Clans; @@ -290,4 +291,74 @@ public async Task Recording_the_same_name_again_skips_the_write() Assert.Equal(firstUpdatedUtc, row.UpdatedUtc); Assert.Single(await context.ClanPlayerNames.ToListAsync()); } + + [Fact] + public async Task Name_lookup_ignores_a_row_whose_guild_id_belongs_to_another_guild() + { + var (store, context, conn, _) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + context.ClanPlayerNames.Add(new ClanPlayerName + { + GuildId = 999UL, + ServerId = serverId, + SteamId = 111UL, + Name = "Alice", + UpdatedUtc = DateTimeOffset.UnixEpoch + }); + await context.SaveChangesAsync(); + + var names = await store.GetNamesAsync(10UL, serverId, [111UL]); + + Assert.Empty(names); + } + + [Fact] + public async Task Recording_a_name_heals_a_stale_guild_id_even_when_the_name_is_unchanged() + { + var (store, context, conn, clock) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + context.ClanPlayerNames.Add(new ClanPlayerName + { + GuildId = 999UL, + ServerId = serverId, + SteamId = 111UL, + Name = "Alice", + UpdatedUtc = DateTimeOffset.UnixEpoch + }); + await context.SaveChangesAsync(); + + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch.AddMinutes(5)); + await store.RecordNameAsync(10UL, serverId, 111UL, "Alice"); + + var row = await context.ClanPlayerNames.SingleAsync(n => n.SteamId == 111UL); + Assert.Equal(10UL, row.GuildId); + Assert.Equal(DateTimeOffset.UnixEpoch.AddMinutes(5), row.UpdatedUtc); + Assert.Single(await context.ClanPlayerNames.ToListAsync()); + } + + [Fact] + public async Task Saving_a_clan_state_heals_a_stale_guild_id_instead_of_throwing() + { + var (store, context, conn, clock) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + context.ClanStates.Add(new ClanState + { + GuildId = 999UL, ServerId = serverId, LastSeenUtc = DateTimeOffset.UnixEpoch + }); + await context.SaveChangesAsync(); + + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch.AddMinutes(5)); + await store.SaveAsync(10UL, serverId, CreateSnapshot()); + + var row = await context.ClanStates.SingleAsync(s => s.ServerId == serverId); + Assert.Equal(10UL, row.GuildId); + Assert.Equal(CreateSnapshot().Name, row.Name); + Assert.Single(await context.ClanStates.ToListAsync()); + } } From 06153947259c7aefa2c12a5711bae2e82cea570d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 22 Jul 2026 21:27:23 +0200 Subject: [PATCH 38/39] Bump RustPlusApi to 2.0.0-beta.5 and surface clan probe diagnostics The published beta.4 parsed clan timestamps as Unix seconds while the wire sends milliseconds, so mapping any real clan payload threw, the library downgraded the throw to a ClientMappingFailed response, and the bot silently classified it as Unavailable: no channels, no rows, no log lines. beta.5 carries the parsing fix. Two observability holes made that failure invisible; both closed: - pass the host ILoggerFactory into RustPlus so the library's own warnings (e.g. a selector mapping failure) land in the bot log instead of a NullLogger - log the connect-time clan probe status once per connect in the supervisor Co-Authored-By: Claude Fable 5 --- Directory.Packages.props | 4 ++-- .../Listening/RustPlusSocketSource.cs | 18 ++++++++++++++---- .../Supervisor/ConnectionSupervisor.cs | 7 +++++++ .../RustPlusSocketSourceTests.cs | 4 ++-- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0a99034b..47d6e06e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,8 +15,8 @@ - - + + diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 906ee74e..c43c6a42 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -8,7 +8,12 @@ namespace RustPlusBot.Features.Connections.Listening; /// Real backed by RustPlusApi. Untested integration shim. /// The logger. -internal sealed partial class RustPlusSocketSource(ILogger logger) : IRustSocketSource +/// Routes RustPlusApi's own diagnostics (e.g. mapping failures) into the +/// host's logging stack; without it the library logs to a NullLogger and every client-side +/// failure it downgrades to a failed response is invisible. +internal sealed partial class RustPlusSocketSource( + ILogger logger, + ILoggerFactory loggerFactory) : IRustSocketSource { /// public IRustServerConnection Create(string ip, int port, ulong steamId, string playerToken) @@ -19,7 +24,7 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p return new RejectedConnection(); } - return new RustPlusServerConnection(ip, port, steamId, token, logger); + return new RustPlusServerConnection(ip, port, steamId, token, logger, loggerFactory); } [LoggerMessage(Level = LogLevel.Warning, @@ -147,12 +152,17 @@ private sealed partial class RustPlusServerConnection : IRustServerConnection private readonly ILogger _logger; private readonly RustPlus _rustPlus; - public RustPlusServerConnection(string ip, int port, ulong steamId, int playerToken, ILogger logger) + public RustPlusServerConnection(string ip, + int port, + ulong steamId, + int playerToken, + ILogger logger, + ILoggerFactory loggerFactory) { _logger = logger; // CONFIRMED: RustPlusConnection(string Server, int Port, ulong PlayerId, int PlayerToken, bool UseFacepunchProxy). var connection = new RustPlusConnection(ip, port, steamId, playerToken, UseFacepunchProxy: false); - _rustPlus = new RustPlus(connection); + _rustPlus = new RustPlus(connection, loggerFactory: loggerFactory); _rustPlus.OnTeamChatReceived += OnTeamChatReceived; _rustPlus.OnSmartDeviceTriggered += OnSmartDeviceTriggered; _rustPlus.OnStorageMonitorTriggered += OnStorageMonitorTriggered; diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 51a98166..e340d79d 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -603,6 +603,7 @@ void OnClanChanged(object? sender, ClanProbeResult probe) // Probe once on connect so clan state is correct after a bot restart, not only after the // next in-game change. An Unavailable result publishes too: the consumer preserves state. var clanProbe = await connection.GetClanInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); + LogClanPrimeProbed(logger, key.Server, clanProbe.Status); await PublishClanStateAsync(key, clanProbe).ConfigureAwait(false); using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct); @@ -1452,6 +1453,12 @@ await eventBus.PublishAsync( [LoggerMessage(Level = LogLevel.Error, Message = "Publishing clan state for server {ServerId} failed.")] private static partial void LogPublishClanStateFailed(ILogger logger, Exception exception, Guid serverId); + // Information, once per connect: an Unavailable prime is deliberately ignored downstream, so + // without this line a clan-probe failure is completely invisible in the logs. + [LoggerMessage(Level = LogLevel.Information, + Message = "Connect-time clan probe for server {ServerId} returned {Status}.")] + private static partial void LogClanPrimeProbed(ILogger logger, Guid serverId, ClanProbeStatus status); + [LoggerMessage(Level = LogLevel.Warning, Message = "Listing smart devices to prime on server {ServerId} failed; priming skipped for this connection.")] private static partial void LogDeviceListFailed(ILogger logger, Exception exception, Guid serverId); diff --git a/tests/RustPlusBot.Features.Connections.Tests/RustPlusSocketSourceTests.cs b/tests/RustPlusBot.Features.Connections.Tests/RustPlusSocketSourceTests.cs index 5528d113..4c67b28e 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/RustPlusSocketSourceTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/RustPlusSocketSourceTests.cs @@ -8,7 +8,7 @@ public sealed class RustPlusSocketSourceTests [Fact] public async Task Create_ReturnsADisposableConnection() { - var source = new RustPlusSocketSource(NullLogger.Instance); + var source = new RustPlusSocketSource(NullLogger.Instance, NullLoggerFactory.Instance); var connection = source.Create("127.0.0.1", 28015, 100UL, "12345"); await using var _ = connection; @@ -19,7 +19,7 @@ public async Task Create_ReturnsADisposableConnection() [Fact] public async Task Create_NonNumericToken_YieldsAuthRejectedConnection() { - var source = new RustPlusSocketSource(NullLogger.Instance); + var source = new RustPlusSocketSource(NullLogger.Instance, NullLoggerFactory.Instance); var connection = source.Create("127.0.0.1", 28015, 100UL, "not-a-number"); await using var _ = connection; From 0bf4b74e0b22db89d1e91d62e337cda347a8c342 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 22 Jul 2026 21:27:31 +0200 Subject: [PATCH 39/39] Drop clan embed pinning and keep gated channels in spec order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #claninfo anchor embeds are no longer pinned: the pin flag, the reconciler's pin step and the gateway pin operation are removed outright. The embeds still sit above the change feed because Discord orders messages by creation time and the reconciler already repairs declaration order. ChannelSpec.Order was declared but never applied, so a capability-gated channel created after the rest of its category (the clan pair appears only once a clan is detected) was appended at the bottom. The reconciler now ends every channel pass with EnsureChannelOrderAsync, which permutes the managed channels' existing position values into spec order — channels outside the list keep their place — and is a pure cache read when the order already matches, so the steady state issues no REST calls. Co-Authored-By: Claude Fable 5 --- .../Gateway/DiscordWorkspaceGateway.cs | 35 ++++++-- .../Gateway/IWorkspaceGateway.cs | 13 ++- .../Reconciler/WorkspaceReconciler.cs | 33 +++---- .../Registry/MessageSpec.cs | 4 +- .../Specs/ServerWorkspaceSpecProvider.cs | 8 +- .../CapabilityGatedChannelTests.cs | 51 ----------- .../Fakes/FakeWorkspaceGateway.cs | 67 +++++++++++--- .../Reconciler/ReconcilerHarness.cs | 10 +-- .../WorkspaceReconcilerOrderTests.cs | 88 +++++++++++++++++++ 9 files changed, 198 insertions(+), 111 deletions(-) create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerOrderTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs index af5163f7..bed04db7 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs @@ -163,24 +163,43 @@ public async Task DeleteMessageAsync(ulong guildId, } /// - public async Task PinMessageAsync(ulong guildId, - ulong channelId, - ulong messageId, + public async Task EnsureChannelOrderAsync(ulong guildId, + ulong categoryId, + IReadOnlyList orderedChannelIds, CancellationToken cancellationToken) { - var channel = client.GetGuild(guildId)?.GetTextChannel(channelId); - if (channel is null) + ArgumentNullException.ThrowIfNull(orderedChannelIds); + var guild = client.GetGuild(guildId); + if (guild is null) { return; } - if (await channel.GetMessageAsync(messageId).ConfigureAwait(false) is IUserMessage message) + var live = orderedChannelIds + .Select(id => guild.GetTextChannel(id)) + .Where(c => c is not null && c.CategoryId == categoryId) + .ToList(); + if (live.Count < 2) { - await message.PinAsync(new RequestOptions + return; + } + + // Discord sorts a category's channels by position, ties by snowflake. + var current = live.OrderBy(c => c.Position).ThenBy(c => c.Id).Select(c => c.Id); + if (current.SequenceEqual(live.Select(c => c.Id))) + { + return; + } + + // Permute the channels' existing position values rather than assigning fresh ones, so every + // channel outside the list keeps its place relative to the reordered block. + var slots = live.Select(c => c.Position).Order().ToList(); + await guild.ReorderChannelsAsync( + live.Select((c, i) => new ReorderChannelProperties(c.Id, slots[i])), + new RequestOptions { CancelToken = cancellationToken }).ConfigureAwait(false); - } } /// diff --git a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs index bb785389..a2b4d041 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs @@ -105,12 +105,17 @@ Task EditMessageAsync(ulong guildId, /// Token to cancel the operation. Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken); - /// Pins a message. A no-op if it is already pinned or already gone. + /// Puts the given channels of a category into the given on-screen order. A cache-read + /// no-op when they already are; otherwise one bulk reorder call that permutes the channels' + /// existing position values, so channels outside the list keep their place. /// The snowflake ID of the guild. - /// The snowflake ID of the channel containing the message. - /// The snowflake ID of the message to pin. + /// The snowflake ID of the category the channels live under. + /// The channel snowflakes in desired top-to-bottom order. /// Token to cancel the operation. - Task PinMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken); + Task EnsureChannelOrderAsync(ulong guildId, + ulong categoryId, + IReadOnlyList orderedChannelIds, + CancellationToken cancellationToken); /// Deletes a channel by snowflake (no-op if already gone). /// The snowflake ID of the guild. diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs index 2d5736c5..127b8918 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs @@ -257,6 +257,19 @@ await backends.Gateway.DeleteChannelAsync(guildId, stale.DiscordChannelId, cance } } + // A capability-gated channel created after the rest of the category is appended at the + // bottom by Discord; restore the declared order. The gateway only issues a reorder call + // when the live order actually differs, so this is a cache read on the steady state. + var ordered = specs.Where(s => result.ContainsKey(s.Key)) + .OrderBy(s => s.Order) + .Select(s => result[s.Key]) + .ToList(); + if (ordered.Count > 1) + { + await backends.Gateway.EnsureChannelOrderAsync(guildId, categoryId, ordered, cancellationToken) + .ConfigureAwait(false); + } + return result; } @@ -334,7 +347,6 @@ await backends.Gateway.DeleteMessageAsync(guildId, channelId, staleId, cancellat } ulong messageId; - var newlyPosted = false; if (item.LiveId is { } liveId) { await backends.Gateway @@ -347,25 +359,6 @@ await backends.Gateway messageId = await backends.Gateway .PostMessageAsync(guildId, channelId, item.Payload, cancellationToken) .ConfigureAwait(false); - newlyPosted = true; - } - - // Pin only on first post: pinning is idempotent but costs an API call, and a - // re-posted message (declaration-order repair) also lands here as newly posted. - if (newlyPosted && item.Spec.Pinned) - { - try - { - await backends.Gateway.PinMessageAsync(guildId, channelId, messageId, cancellationToken) - .ConfigureAwait(false); - } -#pragma warning disable CA1031 // Broad catch: an unpinned embed is still correct; never fail a reconcile over it. - catch (Exception ex) when (!cancellationToken.IsCancellationRequested) -#pragma warning restore CA1031 - { - logger.LogWarning(ex, "Pinning message '{Key}' in guild {GuildId} failed.", item.Spec.Key, - guildId); - } } await backends.Store.SaveMessageAsync( diff --git a/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs b/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs index 1f79459e..ac690ba1 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs @@ -4,6 +4,4 @@ namespace RustPlusBot.Features.Workspace.Registry; /// Global or per-server. /// Stable key (persisted as MessageKey); also the renderer lookup key. /// The of the channel it lives in. -/// True to pin the message when it is first posted, so a channel that also -/// carries transient messages keeps this one reachable from the pin bar. -internal sealed record MessageSpec(WorkspaceScope Scope, string Key, string ChannelKey, bool Pinned = false); +internal sealed record MessageSpec(WorkspaceScope Scope, string Key, string ChannelKey); diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs index 152bdd59..6b38b152 100644 --- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs +++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs @@ -39,10 +39,8 @@ public IEnumerable GetMessageSpecs() => new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerTeam, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerMap, WorkspaceChannelKeys.ServerMap), - // #claninfo also carries a transient change feed, so the anchored embeds are pinned to stay - // reachable once the feed pushes them up. - new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanOverview, WorkspaceChannelKeys.ServerClanInfo, true), - new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanRoster, WorkspaceChannelKeys.ServerClanInfo, true), - new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanInvites, WorkspaceChannelKeys.ServerClanInfo, true), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanOverview, WorkspaceChannelKeys.ServerClanInfo), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanRoster, WorkspaceChannelKeys.ServerClanInfo), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanInvites, WorkspaceChannelKeys.ServerClanInfo), ]; } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs index 30c3cc02..c8dd2c13 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs @@ -85,57 +85,6 @@ public async Task Leaves_ungated_channels_untouched() Assert.True(harness.Gateway.ChannelExists(1, info.DiscordChannelId)); } - [Fact] - public async Task Pins_a_pinned_message_only_when_it_is_newly_posted() - { - var harness = NewHarness() - .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") - .WithMessage(WorkspaceScope.PerServer, "clan.overview", "info", "overview", pinned: true); - var sut = harness.Build(); - - await sut.ReconcileServerAsync(1, ServerId); - - var (pinnedChannelId, pinnedMessageId) = Assert.Single(harness.Gateway.PinnedMessages); - var record = await harness.Store.GetMessageAsync(1, ServerId, "clan.overview"); - Assert.NotNull(record); - Assert.Equal(record!.DiscordMessageId, pinnedMessageId); - Assert.Equal(record.DiscordChannelId, pinnedChannelId); - - // Second reconcile: the message is still live, so it is edited — and not pinned again. - await sut.ReconcileServerAsync(1, ServerId); - - Assert.Single(harness.Gateway.PinnedMessages); - Assert.Equal(1, harness.Gateway.EditedMessages); - } - - [Fact] - public async Task Does_not_pin_messages_that_are_not_marked_pinned() - { - var harness = NewHarness() - .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") - .WithMessage(WorkspaceScope.PerServer, "server.info", "info", "info-text"); - var sut = harness.Build(); - - await sut.ReconcileServerAsync(1, ServerId); - - Assert.Empty(harness.Gateway.PinnedMessages); - } - - [Fact] - public async Task A_failed_pin_does_not_fail_the_reconcile() - { - var harness = NewHarness() - .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") - .WithMessage(WorkspaceScope.PerServer, "clan.overview", "info", "overview", pinned: true); - harness.Gateway.ThrowOnPin = true; - var sut = harness.Build(); - - await sut.ReconcileServerAsync(1, ServerId); - - Assert.Empty(harness.Gateway.PinnedMessages); - Assert.NotNull(await harness.Store.GetMessageAsync(1, ServerId, "clan.overview")); - } - private static ReconcilerHarness GatedHarness(bool available) => NewHarness() .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") .WithChannel(WorkspaceScope.PerServer, "clanchat", "channel.teamchat.name", 1, "clan") diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs index e69247df..f512a303 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs @@ -11,19 +11,20 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway private readonly ConcurrentDictionary _channels = new(); private readonly List _deletedMessageIds = []; private readonly ConcurrentDictionary _messages = new(); - private readonly List<(ulong ChannelId, ulong MessageId)> _pinnedMessages = []; private readonly List _postedPayloads = []; private ulong _nextId = 1000; + private int _nextPosition; public IReadOnlyList MissingPermissions { get; set; } = []; - /// When true, throws (pin-failure path). - public bool ThrowOnPin { get; set; } - public int CreatedCategories { get; private set; } public int CreatedChannels { get; private set; } public int PostedMessages { get; private set; } public int EditedMessages { get; private set; } + + /// How many calls actually moved channels. + public int ReorderCalls { get; private set; } + public IReadOnlyCollection ChannelIds => [.. _channels.Keys]; public IReadOnlyCollection CategoryIds => [.. _categories.Keys]; @@ -33,9 +34,6 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway /// Payloads posted via , in call order. public IReadOnlyList PostedPayloads => _postedPayloads; - /// Pairs pinned via , in call order. - public IReadOnlyList<(ulong ChannelId, ulong MessageId)> PinnedMessages => _pinnedMessages; - public bool CategoryExists(ulong guildId, ulong categoryId) => _categories.ContainsKey(categoryId); public Task FindCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken) @@ -72,7 +70,8 @@ public Task CreateChannelAsync(ulong guildId, CancellationToken cancellationToken) { var id = NextId(); - _channels[id] = new Channel(id, categoryId, name, profile); + // Mirrors Discord: a new channel is appended at the bottom of its category. + _channels[id] = new Channel(id, categoryId, name, profile, _nextPosition++); CreatedChannels++; return Task.FromResult(id); } @@ -84,7 +83,8 @@ public Task ApplyChannelSettingsAsync(ulong guildId, ChannelPermissionProfile profile, CancellationToken cancellationToken) { - _channels[channelId] = new Channel(channelId, categoryId, name, profile); + var position = _channels.TryGetValue(channelId, out var existing) ? existing.Position : _nextPosition++; + _channels[channelId] = new Channel(channelId, categoryId, name, profile, position); return Task.CompletedTask; } @@ -129,14 +129,38 @@ public Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, return Task.CompletedTask; } - public Task PinMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken) + public Task EnsureChannelOrderAsync(ulong guildId, + ulong categoryId, + IReadOnlyList orderedChannelIds, + CancellationToken cancellationToken) { - if (ThrowOnPin) + var live = orderedChannelIds + .Select(id => _channels.TryGetValue(id, out var c) && c.CategoryId == categoryId ? c : null) + .OfType() + .ToList(); + if (live.Count < 2) { - throw new InvalidOperationException($"Pinning message {messageId} failed."); + return Task.CompletedTask; } - _pinnedMessages.Add((channelId, messageId)); + var current = live.OrderBy(c => c.Position).ThenBy(c => c.Id).Select(c => c.Id); + if (current.SequenceEqual(live.Select(c => c.Id))) + { + return Task.CompletedTask; + } + + // Same permutation semantics as the Discord gateway: reuse the channels' existing position + // values so everything else keeps its place. + var slots = live.Select(c => c.Position).Order().ToList(); + for (var i = 0; i < live.Count; i++) + { + _channels[live[i].Id] = live[i] with + { + Position = slots[i] + }; + } + + ReorderCalls++; return Task.CompletedTask; } @@ -154,6 +178,16 @@ public Task DeleteCategoryAsync(ulong guildId, ulong categoryId, CancellationTok public IReadOnlyList GetMissingBotPermissions(ulong guildId) => MissingPermissions; + /// The category's channel ids in on-screen order (position, then snowflake). + /// The category whose channels to list. + public IReadOnlyList ChannelOrder(ulong categoryId) => + [ + .. _channels.Values.Where(c => c.CategoryId == categoryId) + .OrderBy(c => c.Position) + .ThenBy(c => c.Id) + .Select(c => c.Id), + ]; + private ulong NextId() => Interlocked.Increment(ref _nextId); public void ExternallyDeleteChannel(ulong channelId) => _channels.TryRemove(channelId, out _); @@ -167,7 +201,12 @@ public Task DeleteCategoryAsync(ulong guildId, ulong categoryId, CancellationTok private sealed record Category(ulong Id, string Name); - private sealed record Channel(ulong Id, ulong CategoryId, string Name, ChannelPermissionProfile Profile); + private sealed record Channel( + ulong Id, + ulong CategoryId, + string Name, + ChannelPermissionProfile Profile, + int Position); private sealed record Message(ulong Id, ulong ChannelId, MessagePayload Payload); } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs index 5a5c5123..13451eb9 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs @@ -35,10 +35,9 @@ public ReconcilerHarness WithChannel(WorkspaceScope scope, public ReconcilerHarness WithMessage(WorkspaceScope scope, string key, string channelKey, - string text, - bool pinned = false) + string text) { - _messageProviders.Add(new StubMessageProvider([new MessageSpec(scope, key, channelKey, pinned)])); + _messageProviders.Add(new StubMessageProvider([new MessageSpec(scope, key, channelKey)])); _renderers.Add(new StubRenderer(key, text)); return this; } @@ -108,10 +107,9 @@ public ReconcilerBuilderReusing WithChannel(WorkspaceScope scope, public ReconcilerBuilderReusing WithMessage(WorkspaceScope scope, string key, string channelKey, - string text, - bool pinned = false) + string text) { - _messageProviders.Add(new ListMessageProvider([new MessageSpec(scope, key, channelKey, pinned)])); + _messageProviders.Add(new ListMessageProvider([new MessageSpec(scope, key, channelKey)])); _renderers.Add(new ListMessageRenderer(key, text)); return this; } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerOrderTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerOrderTests.cs new file mode 100644 index 00000000..514e471f --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerOrderTests.cs @@ -0,0 +1,88 @@ +using NSubstitute; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Workspace.Registry; + +namespace RustPlusBot.Features.Workspace.Tests.Reconciler; + +/// +/// Covers channel-order enforcement: a capability-gated channel created after the rest of the +/// category (Discord appends it at the bottom) must be moved back into spec order, and a category +/// already in spec order must not trigger any reorder call. +/// +public sealed class WorkspaceReconcilerOrderTests +{ + private static readonly Guid ServerId = Guid.Parse("8f0f0d3e-2b16-4a3e-8f5a-1d1c9f2a7b41"); + + [Fact] + public async Task Moves_a_late_created_gated_channel_into_spec_order() + { + var harness = OrderedHarness(clanAvailable: false); + await harness.Build().ReconcileServerAsync(1, ServerId); + + // The capability turns on afterwards: the clan channel is created last, so Discord appends + // it at the bottom of the category. The reconciler must restore the declared order. + var sut = new ReconcilerBuilderReusing(harness) + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") + .WithChannel(WorkspaceScope.PerServer, "clanchat", "channel.clanchat.name", 1, "clan") + .WithChannel(WorkspaceScope.PerServer, "events", "channel.events.name", 2) + .WithCapability("clan", available: true) + .Build(); + await sut.ReconcileServerAsync(1, ServerId); + + var channels = await harness.Store.GetChannelsAsync(1, ServerId); + var byKey = channels.ToDictionary(c => c.ChannelKey, c => c.DiscordChannelId, StringComparer.Ordinal); + var categoryId = Assert.Single(harness.Gateway.CategoryIds); + Assert.Equal([byKey["info"], byKey["clanchat"], byKey["events"]], + harness.Gateway.ChannelOrder(categoryId)); + } + + [Fact] + public async Task Does_not_reorder_a_category_that_is_already_in_spec_order() + { + var harness = OrderedHarness(clanAvailable: true); + var sut = harness.Build(); + + await sut.ReconcileServerAsync(1, ServerId); + await sut.ReconcileServerAsync(1, ServerId); + + Assert.Equal(0, harness.Gateway.ReorderCalls); + } + + [Fact] + public async Task Reorders_only_once_for_a_late_created_channel() + { + var harness = OrderedHarness(clanAvailable: false); + await harness.Build().ReconcileServerAsync(1, ServerId); + + var sut = new ReconcilerBuilderReusing(harness) + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") + .WithChannel(WorkspaceScope.PerServer, "clanchat", "channel.clanchat.name", 1, "clan") + .WithChannel(WorkspaceScope.PerServer, "events", "channel.events.name", 2) + .WithCapability("clan", available: true) + .Build(); + + await sut.ReconcileServerAsync(1, ServerId); + await sut.ReconcileServerAsync(1, ServerId); + + Assert.Equal(1, harness.Gateway.ReorderCalls); + } + + private static ReconcilerHarness OrderedHarness(bool clanAvailable) + { + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name") + .WithChannel(WorkspaceScope.PerServer, "clanchat", "channel.clanchat.name", 1, "clan") + .WithChannel(WorkspaceScope.PerServer, "events", "channel.events.name", 2) + .WithCapability("clan", clanAvailable); + harness.Servers.GetAsync(1, ServerId, Arg.Any()) + .Returns(new RustServer + { + Id = ServerId, + GuildId = 1, + Name = "Rustopia EU", + Ip = "1.1.1.1", + Port = 28015 + }); + return harness; + } +}