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/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/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/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..7c5e5dda --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-clan-support.md @@ -0,0 +1,3444 @@ +# 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. +- **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. +- 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` | 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 | +| `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` +- 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)`. + +**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. + +- [ ] **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 record** + +`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); +``` + +- [ ] **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) when (!cancellationToken.IsCancellationRequested) +#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) when (!cancellationToken.IsCancellationRequested) +#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` (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. + +- [ ] **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. 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 — no clan events are published. + +- [ ] **Step 3: (removed)** + +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** + +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: (removed)** + +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, 5 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. + +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/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"`. + - `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** + +`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), +``` + +`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** + +`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: Unify the chat sender and locator seams + +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/ChatChannelKind.cs` +- Create: `src/RustPlusBot.Abstractions/Chat/RelayDedupBuffer.cs` +- Delete: `src/RustPlusBot.Features.Chat/Relaying/RelayDedupBuffer.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); `ClanChatLine` and the clan socket members (Task 2). +- Produces: + +```csharp +namespace RustPlusBot.Abstractions.Chat; +public enum ChatChannelKind { Team = 0, Clan = 1 } + +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); +} + +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] + 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 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: `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 dedup 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: 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`. + +On `ConnectionSupervisor`, replace `ITeamChatSender` in the base list with `IChatSender` and rewrite the existing `SendAsync` to dispatch on kind: + +```csharp + /// + 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; + } + } +``` + +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`. + +`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. + +Registration changes to a collection so the bridge can index by kind: + +```csharp + services.AddSingleton(); + services.AddSingleton(); +``` + +`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 +internal sealed class TeamChatRelay( + IEnumerable locators, + ITeamChatWebhookPoster poster, + RelayDedupBuffer dedup, + IServiceScopeFactory scopeFactory) +{ + private readonly IChatChannelLocator _locator = locators.Single(l => l.Kind == ChatChannelKind.Team); +``` + +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`. + +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. + +`ChatServiceCollectionExtensions` — the `RelayDedupBuffer` registration stays but its `using` moves to `RustPlusBot.Abstractions.Chat`. + +- [ ] **Step 7: Run the affected suites** + +Run: `dtk dotnet build RustPlusBot.slnx -maxcpucount:1` +Expected: `0 Error(s)`. + +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 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' +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) +EOF +)" +``` + +--- + +### 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:** +- 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: `ChatChannelKind`, `RelayDedupBuffer`, `IChatSender`, `IChatChannelLocator` (Task 6); `ClanMessageReceivedEvent` (Task 1). +- Produces: + +```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); + +internal sealed class ChatRelay +{ + public Task RelayAsync(RelayedChatLine line, CancellationToken cancellationToken); +} + +internal sealed class ChatInboundProcessor +{ + public Task ProcessAsync(InboundMessage message, CancellationToken cancellationToken); +} + +public interface IChatWebhookPoster +{ + Task PostAsync(ChatChannelKind kind, ulong channelId, string username, string message, CancellationToken cancellationToken); +} +``` + +`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. + +**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. + +**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 1: Write the failing tests** + +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 + [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 +``` + +Plus these cross-kind facts, which are the whole point of the unification: + +```csharp + [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() +``` + +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`. + +`ChatHostedServiceTests` gains: + +```csharp + [Fact] public async Task Relays_a_clan_message_event() + [Fact] public async Task Relays_a_team_message_event() +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dtk dotnet test tests/RustPlusBot.Features.Chat.Tests -maxcpucount:1` +Expected: FAIL — `ChatRelay` and `RelayedChatLine` do not exist. + +- [ ] **Step 3: Generalise the poster** + +Rename the interface and class. `PostAsync` gains a leading `ChatChannelKind kind`. Replace the `WebhookName` constant with: + +```csharp + /// + /// 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", + }; +``` + +Key the client cache on `(ChatChannelKind Kind, ulong ChannelId)`. + +- [ ] **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) +{ + 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(RelayedChatLine line, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(line); + + if (line.FromActivePlayer && line.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) + { + return; // Bot-originated line echoing back; the channel carries only human discussion. + } + + 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(line.GuildId, line.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(line.GuildId, line.ServerId, cancellationToken) + .ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(prefix) && + line.Message.TrimStart().StartsWith(prefix, StringComparison.Ordinal)) + { + return; + } + + await poster.PostAsync(line.Kind, channelId.Value, line.SenderName, line.Message, cancellationToken) + .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) + { + 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; + } +``` + +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: Subscribe to both events** + +`ChatHostedService` — add a second consumer loop, identical in shape to the first, and adapt both event types: + +```csharp + _teamLoop = Task.Run(() => ConsumeTeamMessagesAsync(_cts.Token), CancellationToken.None); + _clanLoop = Task.Run(() => ConsumeClanMessagesAsync(_cts.Token), CancellationToken.None); +``` + +with the bodies mapping to `RelayedChatLine`: + +```csharp + await relay.RelayAsync( + new RelayedChatLine(ChatChannelKind.Team, evt.GuildId, evt.ServerId, evt.SenderName, + evt.Message, evt.FromActivePlayer), cancellationToken).ConfigureAwait(false); +``` + +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. + +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: + +```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 src/RustPlusBot.Features.Chat tests/RustPlusBot.Features.Chat.Tests +git commit -m "$(cat <<'EOF' +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) +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, 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** + +```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. + +**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. + +- [ ] **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, 39 total in `Features.Clans.Tests` (19 + 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, 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, + + /// 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, 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; + } +} +``` + +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** + +`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, 46 total in `Features.Clans.Tests`. + +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 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** + +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); + } + } +} +``` + +`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** + +`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. + +- `SubscribeAsync` → `stateService.ApplyAsync`. + +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()`** + +```csharp +public static IServiceCollection AddClans(this IServiceCollection services) +{ + ArgumentNullException.ThrowIfNull(services); + + services.AddRustPlusBotLocalization(); + + 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, 57 total in `Features.Clans.Tests` (46 + 8 state + 3 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 src/RustPlusBot.Features.Workspace \ + | tr -d '"' | sort -u +``` + +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`** + +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: Update the hard-coded key count** + +`tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs` asserts an exact total: +`Assert.Equal(315, EnglishKeys().Count);`. Update `315` to the new total. Do not delete the +assertion — it is what catches a key added to one file and silently forgotten in the other. + +- [ ] **Step 5: Verify parity** + +Run: `dotnet test tests/RustPlusBot.Localization.Tests -maxcpucount:1` +Expected: PASS. The parity tests fail loudly on any key present in one file and not the other, +and on a stale total. + +- [ ] **Step 6: Commit** + +```bash +git add src/RustPlusBot.Localization tests/RustPlusBot.Localization.Tests +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 **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". + +- [ ] **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 new file mode 100644 index 00000000..5726b00e --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-clan-support-design.md @@ -0,0 +1,452 @@ +# 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, 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. + +## 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`)** — 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 +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 the unified `IChatSender`, which takes a `ChatChannelKind` and returns + `Sent` / `NotConnected` / `Failed`, replacing the team-specific `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. + +**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: + +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.** `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 +`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 +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.** `ClanChatChannelLocator` — a `CachingChannelLocator` subclass over +`WorkspaceChannelKeys.ServerClanChat` implementing the shared `IChatChannelLocator` +alongside `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 +- 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 +- 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 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.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/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.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); 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.Features.Chat/ChatServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Chat/ChatServiceCollectionExtensions.cs index 2e9f2f7a..e151e95d 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; @@ -6,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. @@ -17,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..04ad9969 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? _clanLoop; + private Task? _teamLoop; /// 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,51 @@ 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. 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 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( + 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 +170,14 @@ 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.Warning, Message = "Recording the clan sender's display name failed.")] + private static partial void LogClanNameRecordFailed(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 58% rename from src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs rename to src/RustPlusBot.Features.Chat/Inbound/ChatInboundProcessor.cs index 8b1e3852..2fc937e2 100644 --- a/src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs +++ b/src/RustPlusBot.Features.Chat/Inbound/ChatInboundProcessor.cs @@ -1,35 +1,55 @@ 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; 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. +/// +/// 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( - ITeamChatChannelLocator locator, - ITeamChatSender sender, +internal sealed class ChatInboundProcessor( + IEnumerable locators, + IChatSender sender, RelayDedupBuffer dedup, IServiceScopeFactory scopeFactory) { + private readonly IReadOnlyList _locators = [.. locators]; + /// Processes one observed Discord message. /// The reduced message. /// A cancellation token. /// 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; @@ -51,9 +71,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(kind, key, text); + var result = await sender + .SendAsync(kind, 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/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/ChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs new file mode 100644 index 00000000..c35a4b16 --- /dev/null +++ b/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Chat; +using RustPlusBot.Features.Chat.Webhooks; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Commands; + +namespace RustPlusBot.Features.Chat.Relaying; + +/// +/// 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, 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. +/// The logger. +internal sealed partial class ChatRelay( + IEnumerable locators, + IChatWebhookPoster poster, + RelayDedupBuffer dedup, + IServiceScopeFactory scopeFactory, + ILogger logger) +{ + 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(RelayedChatLine line, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(line); + + if (line.FromActivePlayer && line.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) + { + return; // Bot-originated line echoing back; the channel carries only human discussion. + } + + 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)) + { + LogNoLocatorForKind(logger, line.Kind); + 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(line.GuildId, line.ServerId, cancellationToken) + .ConfigureAwait(false); + if (channelId is null) + { + return; + } + + // A command invocation (e.g. "!pop") gets its reply in game; the bare trigger line is noise in Discord. + var prefix = await GetCommandPrefixAsync(line.GuildId, line.ServerId, cancellationToken) + .ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(prefix) && + line.Message.TrimStart().StartsWith(prefix, StringComparison.Ordinal)) + { + return; + } + + 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)) + { + var muteStore = scope.ServiceProvider.GetRequiredService(); + 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/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/Relaying/TeamChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs deleted file mode 100644 index a640afb8..00000000 --- a/src/RustPlusBot.Features.Chat/Relaying/TeamChatRelay.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using RustPlusBot.Abstractions.Events; -using RustPlusBot.Features.Chat.Webhooks; -using RustPlusBot.Features.Connections.Listening; -using RustPlusBot.Features.Workspace.Locating; -using RustPlusBot.Persistence.Commands; - -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. -/// 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, - ITeamChatWebhookPoster poster, - RelayDedupBuffer dedup, - IServiceScopeFactory scopeFactory) -{ - /// Handles one . - /// The received team message. - /// A cancellation token. - /// A task that completes when the line has been relayed or dropped. - public async Task RelayAsync(TeamMessageReceivedEvent evt, CancellationToken cancellationToken) - { - if (evt.FromActivePlayer && evt.Message.StartsWith(BotTeamChat.Prefix, StringComparison.Ordinal)) - { - return; // Bot-originated line echoing back; #teamchat carries only human discussion. - } - - if (evt.FromActivePlayer && dedup.TryConsume((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 (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); - 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); - } - } -} diff --git a/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs new file mode 100644 index 00000000..d892dc02 --- /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. + internal 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/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/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..221f421b --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Hosting/ClansHostedService.cs @@ -0,0 +1,103 @@ +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)) + { + // 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) + { + // 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); + + [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/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/Messages/ClanInvitesMessageRenderer.cs b/src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs new file mode 100644 index 00000000..f0d1991d --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Messages/ClanInvitesMessageRenderer.cs @@ -0,0 +1,101 @@ +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 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. +/// 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) + { + // 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(); + 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); + } + + /// 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 + : 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..67f9cf75 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs @@ -0,0 +1,200 @@ +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..fdd2afb2 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Messages/ClanRosterMessageRenderer.cs @@ -0,0 +1,213 @@ +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; + + /// + /// 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; + + /// + 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); + 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(); + if (members.Count == 0) + { + continue; + } + + 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) + { + 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); + } + + 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/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/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/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/RustPlusBot.Features.Clans.csproj b/src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj new file mode 100644 index 00000000..c226fea2 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/RustPlusBot.Features.Clans.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs b/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs new file mode 100644 index 00000000..e46dfa42 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs @@ -0,0 +1,91 @@ +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, 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); + + 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; + + /// + 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; + } + + 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(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + var available = await store.HasClanAsync(guildId, id, cancellationToken).ConfigureAwait(false); + + // 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; + } + } + + /// + /// 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/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..9aab38eb --- /dev/null +++ b/src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs @@ -0,0 +1,126 @@ +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/src/RustPlusBot.Features.Clans/State/ClanStateService.cs b/src/RustPlusBot.Features.Clans/State/ClanStateService.cs new file mode 100644 index 00000000..a7627969 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/State/ClanStateService.cs @@ -0,0 +1,315 @@ +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); + + // 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); + + 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); + } + } + + /// + /// 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 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); + if (team is null) + { + // Disconnected: names stay as they are, which is exactly the pre-existing behaviour. + return; + } + + var candidates = team.Members + .Where(m => roster.Contains(m.SteamId) && !string.IsNullOrWhiteSpace(m.Name)); + + 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, + 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); + + [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.Clans/Writing/ClanMotdWriter.cs b/src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs new file mode 100644 index 00000000..dc6b2656 --- /dev/null +++ b/src/RustPlusBot.Features.Clans/Writing/ClanMotdWriter.cs @@ -0,0 +1,52 @@ +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: +/// 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. +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/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/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/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/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/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/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index d61513fb..c43c6a42 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -1,13 +1,19 @@ using System.Globalization; using Microsoft.Extensions.Logging; using RustPlusApi; +using RustPlusApi.Data.Events; using RustPlusBot.Abstractions.Connections; 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) @@ -18,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, @@ -46,6 +52,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 +112,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; } @@ -125,15 +152,22 @@ 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; + _rustPlus.OnClanChatReceived += OnClanChatReceived; + _rustPlus.OnClanChanged += OnClanChanged; } public async Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) @@ -326,6 +360,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 +372,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) when (!cancellationToken.IsCancellationRequested) +#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) when (!cancellationToken.IsCancellationRequested) +#pragma warning restore CA1031 + { + LogQueryFailed(_logger, ex); + return false; + } + } + public async Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) @@ -660,6 +750,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 +812,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/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index d5569cab..e340d79d 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; @@ -88,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) { @@ -363,33 +406,20 @@ public async Task StrobeSmartSwitchAsync( } /// - public async Task SendAsync( + public async Task SetClanMotdAsync( ulong guildId, Guid serverId, - string message, + string motd, CancellationToken cancellationToken) { if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) { - return TeamChatSendResult.NotConnected; + return false; } - try - { - await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); - return TeamChatSendResult.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 TeamChatSendResult.Failed; - } + return await live.Connection + .SetClanMotdAsync(motd, _options.HeartbeatTimeout, cancellationToken) + .ConfigureAwait(false); } [LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")] @@ -547,12 +577,35 @@ 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); + LogClanPrimeProbed(logger, key.Server, clanProbe.Status); + 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 +633,8 @@ void OnStorage(object? sender, StorageMonitorTrigger trigger) connection.TeamMessageReceived -= OnTeamMessage; connection.SmartDeviceTriggered -= OnSmartDevice; connection.StorageMonitorTriggered -= OnStorage; + connection.ClanMessageReceived -= OnClanMessage; + connection.ClanChanged -= OnClanChanged; } } @@ -1033,6 +1088,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 +1447,18 @@ 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); + + // 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/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs index 837c2019..bed04db7 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs @@ -162,6 +162,46 @@ public async Task DeleteMessageAsync(ulong guildId, } } + /// + public async Task EnsureChannelOrderAsync(ulong guildId, + ulong categoryId, + IReadOnlyList orderedChannelIds, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(orderedChannelIds); + var guild = client.GetGuild(guildId); + if (guild is null) + { + return; + } + + var live = orderedChannelIds + .Select(id => guild.GetTextChannel(id)) + .Where(c => c is not null && c.CategoryId == categoryId) + .ToList(); + if (live.Count < 2) + { + 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); + } + /// 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..a2b4d041 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs @@ -105,6 +105,18 @@ Task EditMessageAsync(ulong guildId, /// Token to cancel the operation. Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken); + /// 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 category the channels live under. + /// The channel snowflakes in desired top-to-bottom order. + /// Token to cancel the operation. + 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. /// The snowflake ID of the channel to delete. 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/src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs new file mode 100644 index 00000000..dbd5bab8 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/ClanChatChannelLocator.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Chat; +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), IChatChannelLocator +{ + /// + public ChatChannelKind Kind => ChatChannelKind.Clan; + + /// + 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/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/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/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/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/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..127b8918 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)) { @@ -225,6 +257,19 @@ await backends.Store.SaveChannelAsync( } } + // 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; } 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/WorkspaceRegistry.cs b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs index dd3d8732..4d95382e 100644 --- a/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs +++ b/src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs @@ -1,14 +1,59 @@ namespace RustPlusBot.Features.Workspace.Registry; /// Aggregates contributed spec providers into ordered, scope-filtered views. -/// All channel spec providers. -/// All message spec providers. -internal sealed class WorkspaceRegistry( - IEnumerable channelProviders, - IEnumerable messageProviders) : IWorkspaceRegistry +internal sealed class WorkspaceRegistry : IWorkspaceRegistry { - private readonly List _channels = [.. channelProviders.SelectMany(p => p.GetChannelSpecs())]; - private readonly List _messages = [.. messageProviders.SelectMany(p => p.GetMessageSpecs())]; + private readonly Dictionary _capabilities; + private readonly List _channels; + private readonly List _messages; + + /// 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, + 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) => diff --git a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj index 5fff1dcf..fa8f77c3 100644 --- a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj +++ b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj @@ -2,6 +2,9 @@ + + + diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs index ca22ea62..6b38b152 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,9 @@ public IEnumerable GetMessageSpecs() => new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerEvents, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerTeam, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerMap, WorkspaceChannelKeys.ServerMap), + + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanOverview, WorkspaceChannelKeys.ServerClanInfo), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanRoster, WorkspaceChannelKeys.ServerClanInfo), + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ClanInvites, WorkspaceChannelKeys.ServerClanInfo), ]; } 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.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index 06a9d2d4..1cb7c6c2 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -65,7 +65,9 @@ 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(); services.AddSingleton(); 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.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 26889e45..ab319d1e 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -84,6 +84,12 @@ alarmes + + tchat-clan + + + info-clan + evenements @@ -111,6 +117,150 @@ 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} + + + Aucune invitation en attente. + + + 📨 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} + + + …et {0} groupe(s) de rôles omis pour respecter la limite de taille des embeds Discord + + + 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..cae8bae4 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,150 @@ 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} + + + No pending invites. + + + 📨 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} + + + …and {0} more role group(s) omitted to fit Discord's embed size limit + + + 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/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..179f1a4b --- /dev/null +++ b/src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +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..b32c9500 --- /dev/null +++ b/src/RustPlusBot.Persistence/Clans/ClanStore.cs @@ -0,0 +1,172 @@ +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. +/// Supplies write timestamps. +internal sealed class ClanStore(BotDbContext db, IClock clock) : 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, cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + row = new ClanState + { + ServerId = serverId + }; + db.ClanStates.Add(row); + } + + row.GuildId = guildId; + 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 = clock.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.GuildId == guildId && 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.SteamId == steamId, + cancellationToken) + .ConfigureAwait(false); + + if (row is not null && row.GuildId == guildId && string.Equals(row.Name, name, StringComparison.Ordinal)) + { + return; + } + + if (row is null) + { + row = new ClanPlayerName + { + ServerId = serverId, SteamId = steamId + }; + db.ClanPlayerNames.Add(row); + } + + row.GuildId = guildId; + row.Name = name; + row.UpdatedUtc = clock.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..207f667b --- /dev/null +++ b/src/RustPlusBot.Persistence/Configurations/ClanPlayerNameConfiguration.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 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/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.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/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 596b0493..b65179ba 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; @@ -16,59 +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); - sender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(TeamChatSendResult.Sent); + 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 ITeamChatChannelLocator locator, - out ITeamChatSender sender, - out ITeamChatWebhookPoster poster) + out IChatChannelLocator teamLocator, + out IChatChannelLocator clanLocator, + out IChatSender sender, + out IChatWebhookPoster poster) { - locator = Substitute.For(); - sender = Substitute.For(); - poster = 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(); 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..1496445a --- /dev/null +++ b/tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs @@ -0,0 +1,243 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +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, NullLogger.Instance); + 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()); + } +} 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 626c08af..034266f0 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; @@ -11,40 +12,53 @@ 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, - ITeamChatChannelLocator 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 inboundLocator = Substitute.For(); - var sender = Substitute.For(); + var relayScopeFactory = BuildScopeFactory(muteStore, typeof(IMuteStore)); + var relay = new ChatRelay([teamLocator, clanLocator], poster, dedup, relayScopeFactory, + NullLogger.Instance); + + 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( @@ -52,28 +66,70 @@ 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() + 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 - && !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, "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 TeamMessageReceivedEvent(10UL, Guid.NewGuid(), 1UL, "Alice", "hello", FromActivePlayer: false)); + new ClanMessageReceivedEvent(10UL, serverId, 7UL, "dave", "hi clan", FromActivePlayer: false)); await Task.Delay(20); } - await poster.Received().PostAsync(555UL, "Alice", "hello", Arg.Any()); + 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); } @@ -94,39 +150,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( - ITeamChatChannelLocator locator, - ITeamChatSender sender, + 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/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 deleted file mode 100644 index 13df732b..00000000 --- a/tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -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, ITeamChatSender Sender, RelayDedupBuffer Dedup, IMuteStore Mute) - Build(TeamChatSendResult sendResult = TeamChatSendResult.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()) - .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()); - } - - [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()); - } - - [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()); - } - - [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(10UL, ServerId, "[Alice] hello", Arg.Any()); - Assert.True(dedup.TryConsume((10UL, ServerId), "[Alice] hello")); - } - - [Fact] - public async Task Reports_failed_when_not_connected() - { - var (processor, _, _, _) = Build(TeamChatSendResult.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()); - } -} diff --git a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs b/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs deleted file mode 100644 index 3fc9d44d..00000000 --- a/tests/RustPlusBot.Features.Chat.Tests/TeamChatRelayTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -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, ITeamChatChannelLocator - 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(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()); - } -} diff --git a/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs b/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs new file mode 100644 index 00000000..6220d4da --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/ClansRegistrationTests.cs @@ -0,0 +1,148 @@ +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.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; +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; + +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 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 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() + { + // 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/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 new file mode 100644 index 00000000..c1cf2f21 --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/Messages/ClanRendererTests.cs @@ -0,0 +1,538 @@ +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); + } + + [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(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); + + 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); + + // 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] + 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() + { + 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_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_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.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] + 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..c5b5bd18 --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/Names/ClanNameResolverTests.cs @@ -0,0 +1,79 @@ +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); + } +} 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/ClanCapabilityProviderTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanCapabilityProviderTests.cs new file mode 100644 index 00000000..3821153f --- /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 FakeClock _clock = new(DateTimeOffset.UnixEpoch); + + private readonly IClanStore _store = Substitute.For(); + + [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/ClanSnapshotDifferTests.cs b/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs new file mode 100644 index 00000000..846d98fb --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanSnapshotDifferTests.cs @@ -0,0 +1,354 @@ +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 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() + { + 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 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() + { + 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 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() + { + 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); + } +} 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..0da73a25 --- /dev/null +++ b/tests/RustPlusBot.Features.Clans.Tests/State/ClanStateServiceTests.cs @@ -0,0 +1,415 @@ +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 ILocalizer _localizer = Substitute.For(); + 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(); + private readonly IWorkspaceStore _workspace = 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 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() + { + _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 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); + + // 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. + 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 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); + // 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()); + 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")); + // 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()); + } + + [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, + 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) + { + var services = new ServiceCollection(); + services.AddScoped(_ => _store); + services.AddScoped(_ => _workspace); + services.AddScoped(_ => _names); + services.AddScoped(_ => _reconciler); + services.AddScoped(_ => _query); + 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; + } +} 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)], + []); +} 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/ClanMappingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs new file mode 100644 index 00000000..52e2b90a --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanMappingTests.cs @@ -0,0 +1,170 @@ +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.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); + } + + [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/ClanSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs new file mode 100644 index 00000000..4b9b92a1 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs @@ -0,0 +1,478 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +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; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Discord.Notifications; +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 */ + } + } + + [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; } + 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 66658535..a84c9677 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -27,6 +27,7 @@ internal sealed class FakeRustSocketSource : IRustSocketSource private int _createCount; private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0); + private ClanProbeResult? _pendingClanProbe; private IReadOnlyList _pendingMonuments = []; /// Number of times has been called. Safe to read from any thread. @@ -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 @@ -228,9 +247,21 @@ 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; + /// 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 +289,18 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT return Task.CompletedTask; } + 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 +413,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. 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; 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/CapabilityGatedChannelTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs new file mode 100644 index 00000000..c8dd2c13 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/CapabilityGatedChannelTests.cs @@ -0,0 +1,107 @@ +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 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 ex = Assert.Throws(harness.Build); + + Assert.Contains("ghost", ex.Message, StringComparison.Ordinal); + } + + [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)); + } + + 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..f512a303 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs @@ -13,12 +13,18 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway private readonly ConcurrentDictionary _messages = new(); private readonly List _postedPayloads = []; private ulong _nextId = 1000; + private int _nextPosition; public IReadOnlyList MissingPermissions { 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]; @@ -64,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); } @@ -76,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; } @@ -121,6 +129,41 @@ public Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, return Task.CompletedTask; } + public Task EnsureChannelOrderAsync(ulong guildId, + ulong categoryId, + IReadOnlyList orderedChannelIds, + CancellationToken cancellationToken) + { + var live = orderedChannelIds + .Select(id => _channels.TryGetValue(id, out var c) && c.CategoryId == categoryId ? c : null) + .OfType() + .ToList(); + if (live.Count < 2) + { + return Task.CompletedTask; + } + + 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; + } + public Task DeleteChannelAsync(ulong guildId, ulong channelId, CancellationToken cancellationToken) { _channels.TryRemove(channelId, out _); @@ -135,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 _); @@ -148,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/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/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); + } +} 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..bddd4a01 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ClanMessageKeyParityTests.cs @@ -0,0 +1,22 @@ +using RustPlusBot.Features.Clans.Messages; + +namespace RustPlusBot.Features.Workspace.Tests.Messages; + +/// +/// 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 +{ + [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/Reconciler/ReconcilerHarness.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs index 6d639b2d..13451eb9 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,37 @@ 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) { _messageProviders.Add(new StubMessageProvider([new MessageSpec(scope, key, channelKey)])); _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 +75,54 @@ 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) { _messageProviders.Add(new ListMessageProvider([new MessageSpec(scope, key, channelKey)])); _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 +146,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/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; + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs index 591dd3aa..063a77aa 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)); @@ -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/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 @@ + + diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs index 95e684cb..e280deea 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs @@ -1,11 +1,13 @@ 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; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Features.Workspace.Reconciler; +using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Features.Workspace.Teardown; using RustPlusBot.Persistence; @@ -25,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 @@ -42,7 +49,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)); @@ -50,4 +57,46 @@ 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(); + + // 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 + }); + 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); + } + + 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 23e5b1ef..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(315, EnglishKeys().Count); + Assert.Equal(365, EnglishKeys().Count); } } diff --git a/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs new file mode 100644 index 00000000..03393681 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs @@ -0,0 +1,364 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Clans; +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, IClock Clock) Create() + { + var (context, connection) = SqliteContextFixture.Create(); + 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) + { + 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_for_no_ids() + { + // 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, []); + + 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()); + } + + [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()); + } + + [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()); + } +} 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;