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;
+
+///