Commit 2f3c4d4
Add clan support: detection, conditional channels, chat bridge and info embeds (#60)
* Add clan support design spec
Design for clan detection, a conditional #clanchat bridge, and a #claninfo
channel with anchored embeds plus a live clan-change feed. Introduces a
generic workspace capability seam so channels are provisioned only while a
clan exists and torn down when it does not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add clan support implementation plan
Thirteen TDD tasks covering the abstractions, the connection seam, clan
persistence, the workspace capability gate, the chat bridge, the snapshot
differ, the embeds, the MOTD modal, module wiring, localization and host
composition. Also amend the spec to drop the logo thumbnail, which
MessagePayload cannot carry, and to record the exact verified API types.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Replace the cloned clan bridge with a kind-driven shared bridge
Rework the plan and spec so one ChatRelay, ChatInboundProcessor and webhook
poster serve both team and clan chat via a ChatChannelKind discriminator,
replacing ITeamChatSender and ITeamChatChannelLocator with unified seams.
Features.Clans now owns no chat code. Also drop the unreachable
ClanMotdWriteResult.NotConnected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* Require cancellation-guarded broad catches in the clan plan
Task 2's socket methods copied a broad catch without the
when (!cancellationToken.IsCancellationRequested) guard every sibling method
in RustPlusSocketSource uses, which swallowed caller cancellation. Fix the
plan's code and add the rule to the global constraints so later tasks do not
repeat it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Guard broad clan-connection catches against caller cancellation; assert full clan snapshot mapping
GetClanInfoAsync and SetClanMotdAsync were missing the
`when (!cancellationToken.IsCancellationRequested)` guard that every other
timeout-guarded method in RustPlusSocketSource uses on its broad catch, so a
caller-initiated cancellation was being swallowed and mapped to a fallback
value instead of propagating. Also strengthens
Maps_a_populated_clan_to_a_snapshot to assert every field of the positional
snapshot records, catching future constructor-argument mis-mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* Fix ClanStore review findings: GuildId-agnostic name lookup, IClock injection, skip-write test coverage
RecordNameAsync's existing-row lookup filtered on GuildId even though
ClanPlayerName's primary key is (ServerId, SteamId) only, risking a
DbUpdateException if a row's stored GuildId ever diverged from the
caller-passed value. ClanStore also called DateTimeOffset.UtcNow
directly instead of injecting IClock like every other store in the
persistence layer, leaving its writes untestable with a controlled
clock. Added a test that proves RecordNameAsync's skip-write guard by
asserting UpdatedUtc is unchanged when the same name is recorded
twice, and renamed the no-ids lookup test since an empty IN() clause
returns nothing regardless of whether the empty-collection guard
exists, so the assertion cannot actually prove the guard runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* Carry Task 5 review concerns into the clan plan
Task 12 must update the hard-coded key-count assertion in the localization
parity test. Task 11's capability provider must never answer false on a
transient failure, must register exactly one provider per name, and should
cache within a reconcile pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Note the capability provider constraints in the clan plan
Task 5 already added the InternalsVisibleTo grant, and its review surfaced
three rules the clan capability provider must follow: never answer false on a
transient failure, register exactly once, and cache within a reconcile pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Make the clan renderers' empty-payload contract explicit
Task 5's review showed ServerInfoRefresher will full-reconcile on every tick
for clanless servers unless the clan renderers return an empty payload when no
clan is stored. Record that as a load-bearing requirement in Task 9.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* Close chat locator Kind coverage gap in WorkspaceRegistrationTests
Registration count alone can't catch a swapped Kind between
TeamChatChannelLocator and ClanChatChannelLocator, which would
silently misroute team/clan chat in production. Resolve the
locators and assert each Kind pairs with its expected concrete
type; verified the assertion fails when the two Kind values are
swapped and passes when restored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* Narrow the cancellation-guard constraint to fallback-returning methods
The rule was written for socket probes that must distinguish an internal
timeout token from a caller token. Applied to hosted-service consumer loops it
is actively harmful: those own their CTS, already catch OperationCanceledException
first, and are joined by a StopAsync that only swallows OCE, so the guard would
let a post-cancellation exception fault shutdown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Apply Task 7 chat bridge review findings
Isolate the clan display-name recording failure so it can no longer kill the
clan relay loop; replace reflection-based webhook name pinning with an
internal method and move its test to a dedicated file; log a warning when
ChatRelay has no locator for a channel kind instead of failing silently;
remove a subsumed duplicate hosted-service test. The redundant VSTHRD003
pragma flagged in the review turned out to be load-bearing (removing it
reintroduces the warning under TreatWarningsAsErrors), so it was left in place.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add the clan snapshot differ
Compare consecutive clan snapshots into an ordered, deterministic change list.
A first snapshot and a switch to a different clan both re-baseline silently
rather than announcing every existing member, and an invite-to-member
transition is reported once as an acceptance.
Scaffolds the RustPlusBot.Features.Clans project and its test project,
mirroring the Features.Chat project structure, and wires both into the
solution file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Correct the clan plan's expected test counts
The counts still assumed the superseded Task 7, which created a clan chat
bridge inside Features.Clans. That bridge now lives in Features.Chat, so the
Features.Clans.Tests baseline is 19, not 36, and every later task's expected
total shifts down by 17.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add tests pinning sub-ordering and the old-role guard branch in ClanSnapshotDiffer
Close two coverage gaps found in review of Task 8: deleting the differ's
.Order()/.OrderBy() calls previously left all 19 tests green because every
fixture already declared its collections in ascending id order and each
output category had at most one entry. New tests use descending-order
fixtures for MemberJoined, MemberLeft and InviteSent so the sort is actually
observable, plus a test for the untested "old role id unresolvable" half of
the role-change OR-guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* Warn that Task 12's key grep misses interpolated keys
The clan.roster.perm.* keys are composed from permission flag names and never
appear as quoted literals, so the grep-based collection step would miss them.
A missed key fails no test - it renders as raw key text in a Discord embed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Pin clan #claninfo renderer contracts that mutation testing found unpinned
Close five test-coverage gaps from Task 9 review: the three renderers'
empty-payload paths, the Set MOTD button's credential/role hide-branches,
and the roster field-truncation arithmetic all had implementations that
were correct but not pinned by tests, so six reviewer mutations to
src/ left all 43 existing tests green. Also decorrelate the roster
ordering test's Rank/RoleId fixture, pin one resolver call per render,
and add a cross-assembly test asserting the renderer key literals still
match WorkspaceMessageKeys so a future rename can't silently unhook a
renderer. No src/ changes; each new assertion was verified to fail
against the corresponding mutation before being restored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* Pin the clan capability cache and guard its invalidation race
ClanCapabilityProvider had no behavioural tests at all, yet a "false"
answer from it makes the workspace reconciler delete #clanchat and
#claninfo together with their whole history. Wrapping its store call in
a try/catch that returned false passed the entire suite.
Add ClanCapabilityProviderTests covering the load-bearing contracts: a
store failure faults instead of answering false and is not cached, the
global scope answers false without opening a scope or touching the
store, the answer follows the stored row, the 5s TTL both serves and
expires, and Invalidate drops the entry.
Close the TOCTOU window where a read that queried the store before a
transition committed could write its stale answer into the cache after
Invalidate returned, silently losing the transition reconcile until the
next transition or a process restart (WorkspaceHostedService heals only
at startup and on ChannelDestroyed). A version counter now guards the
cache write: a read that an invalidation overtook simply declines to
populate the cache.
Pin the invalidation call itself with the real provider wired to a
substituted store, so a wrong key or a post-reconcile ordering fails.
Pin the InteractionModuleAssembly registration that ClanMotdModule
discovery depends on, and give Unavailable_changes_nothing a non-null
snapshot case so the early-return guard is genuinely load-bearing
rather than shadowed by the missing-snapshot guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* Translate the French clan channel names
Every other channel name is localized per locale (tchat-equipe,
moniteurs-stockage, interrupteurs), so the clan channels should be too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Align the French clan-info channel name with its sibling
channel.info.name is the singular "info", so the clan variant reads
info-clan rather than introducing a third plural form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>
* Fix the cross-cutting clan defects the whole-branch review found
Render an explicit "no pending invites" embed when a clan is stored but has
none. An empty payload means "leave the previous message on screen" to both
the reconciler and ServerInfoRefresher, so the pinned invites embed listed
resolved invitations forever. The two paths that must stay empty — no server
id, and no clan stored — are unchanged, since a non-empty payload on a
clanless server would trigger a full reconcile on every refresh tick.
Isolate each clan state change in its own try/catch inside the consumer loop.
The broad catch sat outside the await foreach, so one transient store failure
or Discord 5xx ended the loop for the life of the process, after which the
clan channels were never created and, worse, never torn down again.
Harvest member names from the live team snapshot on the HasClan path, the
second of the two name sources the design calls for. Without it a fresh
install renders every roster entry, leader, creator and feed line as a bare
Steam profile link until that member happens to speak in clan chat. It is
best-effort: a disconnected socket or a failure never blocks persistence,
the reconcile or the feed.
Refuse to build a WorkspaceRegistry whose channel specs name a capability no
provider answers for. The reconciler reads "no provider" as "unavailable",
and unavailable deletes the channel and its history, so a host composing
AddWorkspace() without AddClans() would silently destroy every guild's
#clanchat and #claninfo on its first heal. It must fail to start instead.
Budget the roster embed's total text. Per-field 1024 truncation left the
6000-char whole-embed cap unguarded, and EmbedBuilder.Build() throws rather
than trimming; six role groups at their ceiling breach it. Groups are now
added while a running total fits, and a localized notice names how many were
omitted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Correct three clan comments that describe code that does not exist
ClanCapabilityProvider's CacheTtl doc justified its value with "the heal path
reconciles every server on a timer". WorkspaceHostedService heals at startup
and on ChannelDestroyed only; there is no timer. Say what actually bounds the
staleness: reconciles are event-driven and Invalidate, not expiry, is what
guarantees a transition is seen.
ClanMessageKeyParityTests claimed Features.Clans cannot see the internal
WorkspaceMessageKeys. It can — Features.Workspace grants it InternalsVisibleTo.
The constants are independent by choice, which is what makes the assertion
meaningful.
ClanMotdWriter claimed to re-check the permission because roles can change
between render and click. It reads the same cached snapshot the button was
rendered from, so a demotion the poller has not observed yet still gets
through. Describe what it does defend against: a forged component id and a
button clicked from scrollback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Make the workspace registry guard actually fail startup fast
WorkspaceHostedService.StartAsync now resolves IWorkspaceRegistry itself,
outside any try/catch, before scheduling any heal work. Previously the
registry's constructor guard against an unprovided channel capability was
only ever triggered lazily from inside broad catches (the startup heal
and each event-loop consumer), so a host composing AddWorkspace() without
the module owning a capability would start cleanly and only fault quietly
on the first reconcile, contradicting the guard's own comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Skip the team-info socket call when every clan member's name is cached
ClanStateService.RecordTeamNamesAsync now checks IClanStore.GetNamesAsync
against the full roster first and only calls IRustServerQuery.GetTeamInfoAsync
when a roster member's name is still unknown. OnClanChanged fires on every
clan edit, including score changes, and score moves on every kill, so the
previous unconditional call issued a companion-API RPC per kill per server.
In steady state this now costs zero extra RPCs; the socket is only touched
when a genuinely unknown member appears. Best-effort semantics are
unchanged: a null snapshot, a failing query, or an unknown name still never
blocks persistence or the feed post.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Filter clan name lookups by GuildId and self-heal stale GuildId on every clan write
GetNamesAsync now filters on GuildId like the sibling stores, matching
HasClanAsync/GetAsync convention. RecordNameAsync and SaveAsync keep their
lookups keyed on the true primary key (ServerId[+SteamId], not GuildId) to
avoid a duplicate-key crash if GuildId is ever stale, but now assign GuildId
on every write (insert and update) so any stale value self-heals instead of
staying permanently invisible to the newly filtered reads. SaveAsync had the
same latent lookup bug as RecordNameAsync had before an earlier fix; a test
reproduces the pre-fix InvalidOperationException before applying the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Bump RustPlusApi to 2.0.0-beta.5 and surface clan probe diagnostics
The published beta.4 parsed clan timestamps as Unix seconds while the wire
sends milliseconds, so mapping any real clan payload threw, the library
downgraded the throw to a ClientMappingFailed response, and the bot silently
classified it as Unavailable: no channels, no rows, no log lines. beta.5
carries the parsing fix.
Two observability holes made that failure invisible; both closed:
- pass the host ILoggerFactory into RustPlus so the library's own warnings
(e.g. a selector mapping failure) land in the bot log instead of a NullLogger
- log the connect-time clan probe status once per connect in the supervisor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop clan embed pinning and keep gated channels in spec order
The #claninfo anchor embeds are no longer pinned: the pin flag, the
reconciler's pin step and the gateway pin operation are removed outright.
The embeds still sit above the change feed because Discord orders messages
by creation time and the reconciler already repairs declaration order.
ChannelSpec.Order was declared but never applied, so a capability-gated
channel created after the rest of its category (the clan pair appears only
once a clan is detected) was appended at the bottom. The reconciler now ends
every channel pass with EnsureChannelOrderAsync, which permutes the managed
channels' existing position values into spec order — channels outside the
list keep their place — and is a pure cache read when the order already
matches, so the steady state issues no REST calls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>1 parent 47b342e commit 2f3c4d4
131 files changed
Lines changed: 12989 additions & 665 deletions
File tree
- docs/superpowers
- plans
- specs
- src
- RustPlusBot.Abstractions
- Chat
- Connections
- Events
- RustPlusBot.Domain/Clans
- RustPlusBot.Features.Chat
- Hosting
- Inbound
- Relaying
- Webhooks
- RustPlusBot.Features.Clans
- Hosting
- Messages
- Modules
- Names
- Posting
- State
- Writing
- RustPlusBot.Features.Connections
- Listening
- Supervisor
- RustPlusBot.Features.Workspace
- Gateway
- Hosting
- Locating
- Reconciler
- Registry
- Specs
- RustPlusBot.Host
- RustPlusBot.Localization
- RustPlusBot.Persistence
- Clans
- Configurations
- Migrations
- Workspace
- tests
- RustPlusBot.Features.Alarms.Tests
- Hosting
- RustPlusBot.Features.Chat.Tests
- Hosting
- RustPlusBot.Features.Clans.Tests
- Hosting
- Messages
- Names
- State
- Writing
- RustPlusBot.Features.Commands.Tests/Hosting
- RustPlusBot.Features.Connections.Tests
- Fakes
- RustPlusBot.Features.Players.Tests/Hosting
- RustPlusBot.Features.Workspace.Tests
- Fakes
- Hosting
- Messages
- Reconciler
- Registry
- RustPlusBot.Localization.Tests
- RustPlusBot.Persistence.Tests
- Workspace
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
18 | | - | |
19 | | - | |
| 18 | + | |
| 19 | + | |
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
46 | 66 | | |
47 | 67 | | |
48 | 68 | | |
| |||
128 | 148 | | |
129 | 149 | | |
130 | 150 | | |
131 | | - | |
| 151 | + | |
| 152 | + | |
132 | 153 | | |
133 | 154 | | |
134 | 155 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| 13 | + | |
13 | 14 | | |
14 | 15 | | |
15 | 16 | | |
| |||
39 | 40 | | |
40 | 41 | | |
41 | 42 | | |
| 43 | + | |
42 | 44 | | |
43 | 45 | | |
44 | 46 | | |
| |||
0 commit comments