Skip to content

Commit 70374af

Browse files
HandyS11claude
andcommitted
Compose the clan module into the host
Register AddClans, document the feature and its API limits in the README, and apply the repository formatting profile. Full suite: 1217 passed, 1 skipped, 0 failed across 19 test assemblies (Clans 82, Connections 112, Chat 54, Workspace 102, Persistence 125, Commands 149, Alarms 67, ItemData.Generator 63, Pairing 56, ItemData 53, Events 52, Abstractions 48, Switches 40, StorageMonitors 37, Wipes 26, Players 22, Discord 20, Localization 12, Map 97+1 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6a768d2 commit 70374af

21 files changed

Lines changed: 180 additions & 110 deletions

File tree

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,26 @@ recycle/craft/research/decay/upkeep calculators are all shipped. Cameras are nex
4343
- Two-way relay between in-game team chat and a per-server `#teamchat` channel
4444
(via a managed webhook), with echo/loop suppression.
4545

46+
### Clans
47+
48+
- **Conditional channels**`#clanchat` and `#claninfo` appear automatically when
49+
the paired player is in a clan, and are removed again when they leave; no
50+
command to run either way.
51+
- **`#clanchat`** — two-way relay between in-game clan chat and the channel, sharing
52+
the same echo/loop suppression as the team bridge.
53+
- **`#claninfo`** — three pinned auto-refreshing embeds — **Overview** (score,
54+
member count, creation date, leader, creator, MOTD), **Roster** (members grouped
55+
by clan role, online first, with each role's permissions), and **Invites**
56+
refreshed on the existing `Workspace:InfoRefreshInterval`, plus a live feed of
57+
clan changes (members joining/leaving, promotions/demotions, invites, rename,
58+
MOTD, logo, colour, score, dissolution).
59+
- **Set MOTD** — a button on the overview embed opens a modal that writes the MOTD
60+
back to the game; it is offered only when the paired player's in-game clan role
61+
carries the permission.
62+
- **API limits** — RustPlusApi 2.0.0-beta.4 exposes no clan audit log, no per-member
63+
scores, and no kick/invite/promote actions, so none of those are implemented; the
64+
feed is instead derived by diffing successive clan snapshots.
65+
4666
### In-game `!commands`
4767

4868
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).
128148
| `RustPlusBot.Features.Workspace` | Channel/message provisioning, reconciler, `#info`/`#setup`/`#settings` surfaces |
129149
| `RustPlusBot.Features.Pairing` | FCM pairing listener, credential intake, account disconnect |
130150
| `RustPlusBot.Features.Connections` | Live socket supervisor, hot-swap/failover, Rust+ query seam |
131-
| `RustPlusBot.Features.Chat` | Two-way `#teamchat` ↔ in-game chat bridge |
151+
| `RustPlusBot.Features.Chat` | Two-way `#teamchat` / `#clanchat` ↔ in-game chat bridges |
152+
| `RustPlusBot.Features.Clans` | Clan state, `#claninfo` embeds and change feed, Set MOTD |
132153
| `RustPlusBot.Features.Commands` | In-game `!commands`, slash surfaces, `/help`/`/leader` |
133154
| `RustPlusBot.Features.Events` | Live map-event classification + `#events` feed |
134155
| `RustPlusBot.Features.Map` | Map image rendering with toggleable layers |

src/RustPlusBot.Features.Chat/Hosting/ChatHostedService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ internal sealed partial class ChatHostedService(
2727
ILogger<ChatHostedService> logger) : IHostedService, IDisposable
2828
{
2929
private readonly CancellationTokenSource _cts = new();
30-
private Task? _teamLoop;
3130
private Task? _clanLoop;
31+
private Task? _teamLoop;
3232

3333
/// <inheritdoc />
3434
public void Dispose() => _cts.Dispose();

src/RustPlusBot.Features.Clans/Messages/ClanOverviewMessageRenderer.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ private Task<IReadOnlyDictionary<ulong, string>> ResolveNamesAsync(
120120
CancellationToken cancellationToken)
121121
{
122122
// One batched call: the embed needs at most the leader, the creator and the MOTD author.
123-
var ids = new HashSet<ulong> { clan.Creator };
123+
var ids = new HashSet<ulong>
124+
{
125+
clan.Creator
126+
};
124127
if (leader is not null)
125128
{
126129
ids.Add(leader.SteamId);

src/RustPlusBot.Features.Clans/State/ClanCapabilityProvider.cs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,6 @@ internal sealed class ClanCapabilityProvider(IServiceScopeFactory scopeFactory,
3636
/// <inheritdoc />
3737
public string Capability => WorkspaceCapabilities.Clan;
3838

39-
/// <summary>
40-
/// Drops the cached answer for a server. Called by <see cref="ClanStateService"/> immediately
41-
/// before it reconciles a clan transition, so the reconcile sees the just-written row rather
42-
/// than a stale pre-transition answer.
43-
/// </summary>
44-
/// <param name="guildId">The guild snowflake.</param>
45-
/// <param name="serverId">The server id.</param>
46-
public void Invalidate(ulong guildId, Guid serverId)
47-
{
48-
// Bump first: an in-flight read that observes the new epoch after its query will not cache.
49-
Interlocked.Increment(ref _epoch);
50-
_cache.TryRemove((guildId, serverId), out _);
51-
}
52-
5339
/// <inheritdoc />
5440
public async ValueTask<bool> IsAvailableAsync(ulong guildId, Guid? serverId, CancellationToken cancellationToken)
5541
{
@@ -86,4 +72,18 @@ public async ValueTask<bool> IsAvailableAsync(ulong guildId, Guid? serverId, Can
8672
return available;
8773
}
8874
}
75+
76+
/// <summary>
77+
/// Drops the cached answer for a server. Called by <see cref="ClanStateService"/> immediately
78+
/// before it reconciles a clan transition, so the reconcile sees the just-written row rather
79+
/// than a stale pre-transition answer.
80+
/// </summary>
81+
/// <param name="guildId">The guild snowflake.</param>
82+
/// <param name="serverId">The server id.</param>
83+
public void Invalidate(ulong guildId, Guid serverId)
84+
{
85+
// Bump first: an in-flight read that observes the new epoch after its query will not cache.
86+
Interlocked.Increment(ref _epoch);
87+
_cache.TryRemove((guildId, serverId), out _);
88+
}
8989
}

src/RustPlusBot.Features.Clans/State/ClanSnapshotDiffer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ public static IReadOnlyList<ClanChange> Diff(ClanSnapshot? previous, ClanSnapsho
5353
// An id that left Invites and appeared in Members in one step is an acceptance, reported
5454
// once — never as a join plus a revocation.
5555
var accepted = previousInvites
56-
.Where(id => !currentInvites.Contains(id) && currentMembers.ContainsKey(id) && !previousMembers.ContainsKey(id))
56+
.Where(id =>
57+
!currentInvites.Contains(id) && currentMembers.ContainsKey(id) && !previousMembers.ContainsKey(id))
5758
.ToHashSet();
5859

5960
foreach (var id in currentMembers.Keys.Where(id => !previousMembers.ContainsKey(id) && !accepted.Contains(id))

src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs

Lines changed: 42 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,48 @@ public async ValueTask DisposeAsync()
8989
_gate.Dispose();
9090
}
9191

92+
/// <inheritdoc />
93+
public async Task<ChatSendResult> SendAsync(
94+
ChatChannelKind kind,
95+
ulong guildId,
96+
Guid serverId,
97+
string message,
98+
CancellationToken cancellationToken)
99+
{
100+
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
101+
{
102+
return ChatSendResult.NotConnected;
103+
}
104+
105+
try
106+
{
107+
switch (kind)
108+
{
109+
case ChatChannelKind.Team:
110+
await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false);
111+
break;
112+
case ChatChannelKind.Clan:
113+
await live.Connection.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false);
114+
break;
115+
default:
116+
return ChatSendResult.Failed;
117+
}
118+
119+
return ChatSendResult.Sent;
120+
}
121+
catch (OperationCanceledException)
122+
{
123+
throw;
124+
}
125+
#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed.
126+
catch (Exception ex)
127+
#pragma warning restore CA1031
128+
{
129+
LogSendFailed(logger, ex, serverId);
130+
return ChatSendResult.Failed;
131+
}
132+
}
133+
92134
/// <inheritdoc />
93135
public async Task StartAllAsync(CancellationToken cancellationToken = default)
94136
{
@@ -380,48 +422,6 @@ public async Task<bool> SetClanMotdAsync(
380422
.ConfigureAwait(false);
381423
}
382424

383-
/// <inheritdoc />
384-
public async Task<ChatSendResult> SendAsync(
385-
ChatChannelKind kind,
386-
ulong guildId,
387-
Guid serverId,
388-
string message,
389-
CancellationToken cancellationToken)
390-
{
391-
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
392-
{
393-
return ChatSendResult.NotConnected;
394-
}
395-
396-
try
397-
{
398-
switch (kind)
399-
{
400-
case ChatChannelKind.Team:
401-
await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false);
402-
break;
403-
case ChatChannelKind.Clan:
404-
await live.Connection.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false);
405-
break;
406-
default:
407-
return ChatSendResult.Failed;
408-
}
409-
410-
return ChatSendResult.Sent;
411-
}
412-
catch (OperationCanceledException)
413-
{
414-
throw;
415-
}
416-
#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed.
417-
catch (Exception ex)
418-
#pragma warning restore CA1031
419-
{
420-
LogSendFailed(logger, ex, serverId);
421-
return ChatSendResult.Failed;
422-
}
423-
}
424-
425425
[LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")]
426426
private static partial void LogLoopFaulted(ILogger logger, Exception exception, Guid serverId);
427427

src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ internal sealed class WorkspaceRegistry(
99
IEnumerable<IMessageSpecProvider> messageProviders,
1010
IEnumerable<IWorkspaceCapabilityProvider> capabilityProviders) : IWorkspaceRegistry
1111
{
12-
private readonly List<ChannelSpec> _channels = [.. channelProviders.SelectMany(p => p.GetChannelSpecs())];
13-
private readonly List<MessageSpec> _messages = [.. messageProviders.SelectMany(p => p.GetMessageSpecs())];
14-
1512
private readonly Dictionary<string, IWorkspaceCapabilityProvider> _capabilities =
1613
capabilityProviders.ToDictionary(p => p.Capability, StringComparer.Ordinal);
1714

15+
private readonly List<ChannelSpec> _channels = [.. channelProviders.SelectMany(p => p.GetChannelSpecs())];
16+
private readonly List<MessageSpec> _messages = [.. messageProviders.SelectMany(p => p.GetMessageSpecs())];
17+
1818
/// <inheritdoc />
1919
public ValueTask<bool> IsCapabilityAvailableAsync(string capability,
2020
ulong guildId,

src/RustPlusBot.Host/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using RustPlusBot.Discord;
66
using RustPlusBot.Features.Alarms;
77
using RustPlusBot.Features.Chat;
8+
using RustPlusBot.Features.Clans;
89
using RustPlusBot.Features.Commands;
910
using RustPlusBot.Features.Connections;
1011
using RustPlusBot.Features.Events;
@@ -69,6 +70,7 @@
6970
.ValidateOnStart();
7071
builder.Services.AddConnections();
7172
builder.Services.AddChat();
73+
builder.Services.AddClans();
7274
builder.Services.AddOptions<CommandOptions>()
7375
.Bind(builder.Configuration.GetSection("Commands"))
7476
.Validate(static o => o.Cooldown > TimeSpan.Zero, "Commands:Cooldown must be positive.")

src/RustPlusBot.Host/RustPlusBot.Host.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<ProjectReference Include="..\RustPlusBot.Features.Pairing\RustPlusBot.Features.Pairing.csproj" />
2727
<ProjectReference Include="..\RustPlusBot.Features.Connections\RustPlusBot.Features.Connections.csproj" />
2828
<ProjectReference Include="..\RustPlusBot.Features.Chat\RustPlusBot.Features.Chat.csproj" />
29+
<ProjectReference Include="..\RustPlusBot.Features.Clans\RustPlusBot.Features.Clans.csproj" />
2930
<ProjectReference Include="..\RustPlusBot.Features.Commands\RustPlusBot.Features.Commands.csproj" />
3031
<ProjectReference Include="..\RustPlusBot.Features.Events\RustPlusBot.Features.Events.csproj" />
3132
<ProjectReference Include="..\RustPlusBot.Features.Switches\RustPlusBot.Features.Switches.csproj" />

src/RustPlusBot.Persistence/Clans/ClanSnapshotSerializer.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Text.Json;
2-
using RustPlusBot.Abstractions.Connections;
32

43
namespace RustPlusBot.Persistence.Clans;
54

0 commit comments

Comments
 (0)