Skip to content

Commit 6f1a7d1

Browse files
HandyS11claude
andauthored
Subsystem 4a: Smart Switch pairing & control (#switches channel, ON/OFF/Strobe/Rename) (#18)
* chore(deps): bump RustPlusApi/RustPlusApi.Fcm to 2.0.0-beta.2 for 4a Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(switches): add SwitchPairedEvent and SwitchStateChangedEvent * feat(switches): add SmartSwitch entity, config, and SmartSwitches migration * feat(switches): add ISwitchStore/SwitchStore with CRUD over SmartSwitch * feat(switches): add #switches channel spec, EN/FR name, and channel locator * feat(switches): add smart-switch read/control/event to the connection seam * feat(switches): supervisor smart-switch query, connect priming, and trigger relay * feat(switches): add ServerService.GetByEndpointAsync lookup-only path * feat(switches): route FCM entity pairings to SwitchPairedEvent via Facepunch ServerId Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(switches): scaffold Features.Switches project + EN/FR localizer and component ids * feat(switches): add SwitchEmbedRenderer (embed, control row, pairing prompt) * feat(switches): add DiscordSwitchChannelPoster (edit-by-id with self-heal) * feat(switches): add SwitchPairingCoordinator (prompt, accept, dedupe) * feat(switches): add SwitchStateRelay (state re-render + unreachable on disconnect) * feat(switches): add SwitchComponentModule + rename modal (thin interaction surface) * feat(switches): hosted service + AddSwitches DI + Host wiring (4a end-to-end) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(switches): address PR review — guard empty Facepunch GUID, idempotent AddAsync, delete real dismiss prompt - PairingHandler: only backfill FacepunchServerId when non-empty, so servers paired without a Facepunch GUID don't all collide on Guid.Empty (which would break entity attribution / throw on resolution). - ServerService.GetByFacepunchServerIdAsync: treat Guid.Empty as no-match and use FirstOrDefault so resolution can never throw on duplicate legacy ids. - SwitchStore.AddAsync: recover idempotently from the unique-index violation when two users accept the same pending pairing concurrently (detach + return the winner row) instead of surfacing a DbUpdateException as an interaction failure. + test. - SwitchComponentModule.DismissAsync: delete the component's source prompt message (IComponentInteraction.Message) rather than the ephemeral interaction response, which left the prompt lingering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(gitignore): add .superpowers directory to ignore list --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0a5500c commit 6f1a7d1

64 files changed

Lines changed: 4509 additions & 14 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,8 @@ docs/obj/
418418
docs/superpowers/
419419
docs/product/
420420

421+
.superpowers/
422+
421423
# Stryker mutation testing output
422424
StrykerOutput/
423425

Directory.Packages.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
1212
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
1313
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
14-
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.1" />
15-
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.1" />
14+
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.2" />
15+
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.2" />
1616
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
1717
<PackageVersion Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
1818
<!-- Pinned to override the transitive 2.1.11 pulled by Microsoft.EntityFrameworkCore.Sqlite,

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
1212
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
1313
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
14+
<Project Path="src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj" />
1415
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
1516
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
1617
</Folder>
@@ -21,6 +22,7 @@
2122
<Project Path="tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj" />
2223
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
2324
<Project Path="tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj" />
25+
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
2426
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
2527
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
2628
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />

src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,43 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
5555
ulong guildId,
5656
Guid serverId,
5757
CancellationToken cancellationToken);
58+
59+
/// <summary>Reads a smart switch's on/off state, or null when there is no live socket or the call fails.</summary>
60+
/// <param name="guildId">The owning guild snowflake.</param>
61+
/// <param name="serverId">The target server id.</param>
62+
/// <param name="entityId">The in-game smart-switch entity id.</param>
63+
/// <param name="cancellationToken">A cancellation token.</param>
64+
/// <returns>True/false for on/off, or null when unavailable.</returns>
65+
Task<bool?> GetSmartSwitchStateAsync(ulong guildId,
66+
Guid serverId,
67+
ulong entityId,
68+
CancellationToken cancellationToken);
69+
70+
/// <summary>Sets a smart switch on/off; returns false when there is no live socket or the call fails.</summary>
71+
/// <param name="guildId">The owning guild snowflake.</param>
72+
/// <param name="serverId">The target server id.</param>
73+
/// <param name="entityId">The in-game smart-switch entity id.</param>
74+
/// <param name="value">True to turn on, false to turn off.</param>
75+
/// <param name="cancellationToken">A cancellation token.</param>
76+
/// <returns>True on success; false when unavailable or the call fails.</returns>
77+
Task<bool> SetSmartSwitchAsync(ulong guildId,
78+
Guid serverId,
79+
ulong entityId,
80+
bool value,
81+
CancellationToken cancellationToken);
82+
83+
/// <summary>Strobes a smart switch; returns false when there is no live socket or the call fails.</summary>
84+
/// <param name="guildId">The owning guild snowflake.</param>
85+
/// <param name="serverId">The target server id.</param>
86+
/// <param name="entityId">The in-game smart-switch entity id.</param>
87+
/// <param name="timeoutMs">The in-game strobe duration in milliseconds.</param>
88+
/// <param name="value">The terminal value after strobing.</param>
89+
/// <param name="cancellationToken">A cancellation token.</param>
90+
/// <returns>True on success; false when unavailable or the call fails.</returns>
91+
Task<bool> StrobeSmartSwitchAsync(ulong guildId,
92+
Guid serverId,
93+
ulong entityId,
94+
int timeoutMs,
95+
bool value,
96+
CancellationToken cancellationToken);
5897
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>Raised when a Smart Switch is paired in-game and resolved to a known server (awaiting user validation).</summary>
4+
/// <param name="GuildId">The owning Discord guild snowflake.</param>
5+
/// <param name="ServerId">The local RustServer id the entity belongs to.</param>
6+
/// <param name="EntityId">The in-game smart-switch entity id.</param>
7+
public sealed record SwitchPairedEvent(ulong GuildId, Guid ServerId, ulong EntityId);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>Raised when a Smart Switch's live on/off state is observed (prime or trigger).</summary>
4+
/// <param name="GuildId">The owning Discord guild snowflake.</param>
5+
/// <param name="ServerId">The local RustServer id the entity belongs to.</param>
6+
/// <param name="EntityId">The in-game smart-switch entity id.</param>
7+
/// <param name="IsActive">True when the switch is on.</param>
8+
public sealed record SwitchStateChangedEvent(ulong GuildId, Guid ServerId, ulong EntityId, bool IsActive);

src/RustPlusBot.Domain/Servers/RustServer.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ public sealed class RustServer
2020

2121
/// <summary>The Discord user who added this server.</summary>
2222
public ulong AddedByUserId { get; set; }
23+
24+
/// <summary>The Facepunch server GUID from FCM pairings, backfilled on server pairing; null until first seen. Used to attribute entity pairings.</summary>
25+
public Guid? FacepunchServerId { get; set; }
2326
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
namespace RustPlusBot.Domain.Switches;
2+
3+
/// <summary>A paired Smart Switch the bot manages, surviving restarts. Guild- and server-scoped.</summary>
4+
public sealed class SmartSwitch
5+
{
6+
/// <summary>Surrogate primary key.</summary>
7+
public Guid Id { get; set; } = Guid.NewGuid();
8+
9+
/// <summary>The owning Discord guild snowflake.</summary>
10+
public ulong GuildId { get; set; }
11+
12+
/// <summary>The server this switch belongs to (FK to RustServer, cascade delete).</summary>
13+
public Guid ServerId { get; set; }
14+
15+
/// <summary>The in-game smart-switch entity id.</summary>
16+
public ulong EntityId { get; set; }
17+
18+
/// <summary>User-facing label; defaults to a generated "Switch &lt;EntityId&gt;" (the FCM event carries no name).</summary>
19+
public string Name { get; set; } = string.Empty;
20+
21+
/// <summary>The Discord message id of this switch's embed, or null until first posted.</summary>
22+
public ulong? MessageId { get; set; }
23+
24+
/// <summary>The Discord user who accepted (validated) the pairing.</summary>
25+
public ulong PairedByUserId { get; set; }
26+
27+
/// <summary>The last observed on/off state.</summary>
28+
public bool LastIsActive { get; set; }
29+
30+
/// <summary>When the switch was accepted (UTC).</summary>
31+
public DateTimeOffset CreatedUtc { get; set; }
32+
}

src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,37 @@ internal interface IRustServerConnection : IAsyncDisposable
4747
/// <returns>True if the promotion succeeded; false on failure/timeout.</returns>
4848
Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken);
4949

50+
/// <summary>Reads a smart switch's on/off state, or null on failure/timeout. Also primes the socket's interest in the entity.</summary>
51+
/// <param name="entityId">The in-game smart-switch entity id.</param>
52+
/// <param name="timeout">How long to wait for the response.</param>
53+
/// <param name="cancellationToken">A cancellation token.</param>
54+
/// <returns>True/false for on/off, or null on failure/timeout.</returns>
55+
Task<bool?> GetSmartSwitchInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken);
56+
57+
/// <summary>Sets a smart switch on/off; returns true on success, false on failure/timeout.</summary>
58+
/// <param name="entityId">The in-game smart-switch entity id.</param>
59+
/// <param name="value">True to turn on, false to turn off.</param>
60+
/// <param name="timeout">How long to wait for the response.</param>
61+
/// <param name="cancellationToken">A cancellation token.</param>
62+
/// <returns>True on success; false on failure/timeout.</returns>
63+
Task<bool> SetSmartSwitchValueAsync(ulong entityId,
64+
bool value,
65+
TimeSpan timeout,
66+
CancellationToken cancellationToken);
67+
68+
/// <summary>Strobes a smart switch; returns true on success, false on failure/timeout.</summary>
69+
/// <param name="entityId">The in-game smart-switch entity id.</param>
70+
/// <param name="timeoutMs">The in-game strobe duration in milliseconds.</param>
71+
/// <param name="value">The terminal value after strobing.</param>
72+
/// <param name="timeout">How long to wait for the response.</param>
73+
/// <param name="cancellationToken">A cancellation token.</param>
74+
/// <returns>True on success; false on failure/timeout.</returns>
75+
Task<bool> StrobeSmartSwitchAsync(ulong entityId,
76+
int timeoutMs,
77+
bool value,
78+
TimeSpan timeout,
79+
CancellationToken cancellationToken);
80+
5081
/// <summary>Polls the current map markers the bot tracks (cargo ship, patrol helicopter, chinook), for diffing by id. Throws on failure.</summary>
5182
/// <param name="timeout">How long to wait for the response.</param>
5283
/// <param name="cancellationToken">A cancellation token.</param>
@@ -75,4 +106,7 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
75106

76107
/// <summary>Raised for every in-game team chat line received on this socket.</summary>
77108
event EventHandler<TeamChatLine>? TeamMessageReceived;
109+
110+
/// <summary>Raised when a smart switch's state changes in-game; carries the entity id.</summary>
111+
event EventHandler<ulong>? SmartSwitchTriggered;
78112
}

src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT
4848
public Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) =>
4949
Task.FromResult(false);
5050

51+
public Task<bool?> GetSmartSwitchInfoAsync(ulong entityId,
52+
TimeSpan timeout,
53+
CancellationToken cancellationToken) =>
54+
Task.FromResult<bool?>(null);
55+
56+
public Task<bool> SetSmartSwitchValueAsync(ulong entityId,
57+
bool value,
58+
TimeSpan timeout,
59+
CancellationToken cancellationToken) =>
60+
Task.FromResult(false);
61+
62+
public Task<bool> StrobeSmartSwitchAsync(ulong entityId,
63+
int timeoutMs,
64+
bool value,
65+
TimeSpan timeout,
66+
CancellationToken cancellationToken) =>
67+
Task.FromResult(false);
68+
5169
public Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(TimeSpan timeout,
5270
CancellationToken cancellationToken = default) =>
5371
Task.FromResult<IReadOnlyList<MapMarkerSnapshot>>([]);
@@ -69,6 +87,12 @@ public event EventHandler<TeamChatLine>? TeamMessageReceived
6987
remove { _ = value; }
7088
}
7189

90+
public event EventHandler<ulong>? SmartSwitchTriggered
91+
{
92+
add { _ = value; }
93+
remove { _ = value; }
94+
}
95+
7296
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
7397
}
7498

@@ -92,6 +116,7 @@ public RustPlusServerConnection(string ip, int port, ulong steamId, int playerTo
92116
var connection = new RustPlusConnection(ip, port, steamId, playerToken, UseFacepunchProxy: false);
93117
_rustPlus = new RustPlus(connection);
94118
_rustPlus.OnTeamChatReceived += OnTeamChatReceived;
119+
_rustPlus.OnSmartSwitchTriggered += OnSmartSwitchTriggered;
95120
}
96121

97122
public async Task<SocketConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken)
@@ -320,6 +345,94 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,
320345
}
321346
}
322347

348+
public event EventHandler<ulong>? SmartSwitchTriggered;
349+
350+
public async Task<bool?> GetSmartSwitchInfoAsync(ulong entityId,
351+
TimeSpan timeout,
352+
CancellationToken cancellationToken)
353+
{
354+
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
355+
timeoutCts.CancelAfter(timeout);
356+
try
357+
{
358+
// CONFIRMED (2.0.0-beta.2): GetSmartSwitchInfoAsync(ulong, CancellationToken) returns
359+
// Task of Response of SmartSwitchInfo; Response.IsSuccess and Response.Data are the accessors and
360+
// SmartSwitchInfo.IsActive is a bool. The call also primes the socket's interest in this
361+
// entity, so OnSmartSwitchTriggered fires for it thereafter.
362+
var response = await _rustPlus.GetSmartSwitchInfoAsync(entityId, timeoutCts.Token)
363+
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
364+
return response.IsSuccess && response.Data is { } info ? info.IsActive : null;
365+
}
366+
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
367+
{
368+
return null;
369+
}
370+
#pragma warning disable CA1031 // Broad catch: any switch-info failure maps to null; never surface a token/secret.
371+
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
372+
#pragma warning restore CA1031
373+
{
374+
LogQueryFailed(_logger, ex);
375+
return null;
376+
}
377+
}
378+
379+
public async Task<bool> SetSmartSwitchValueAsync(ulong entityId,
380+
bool value,
381+
TimeSpan timeout,
382+
CancellationToken cancellationToken)
383+
{
384+
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
385+
timeoutCts.CancelAfter(timeout);
386+
try
387+
{
388+
// CONFIRMED (2.0.0-beta.2): SetSmartSwitchValueAsync(ulong, bool, CancellationToken) returns
389+
// Task of Response of SmartSwitchInfo; Response.IsSuccess indicates the outcome.
390+
var response = await _rustPlus.SetSmartSwitchValueAsync(entityId, value, timeoutCts.Token)
391+
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
392+
return response.IsSuccess;
393+
}
394+
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
395+
{
396+
return false;
397+
}
398+
#pragma warning disable CA1031 // Broad catch: any set failure maps to false; never surface a token/secret.
399+
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
400+
#pragma warning restore CA1031
401+
{
402+
LogQueryFailed(_logger, ex);
403+
return false;
404+
}
405+
}
406+
407+
public async Task<bool> StrobeSmartSwitchAsync(ulong entityId,
408+
int timeoutMs,
409+
bool value,
410+
TimeSpan timeout,
411+
CancellationToken cancellationToken)
412+
{
413+
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
414+
timeoutCts.CancelAfter(timeout);
415+
try
416+
{
417+
// CONFIRMED (2.0.0-beta.2): StrobeSmartSwitchAsync(ulong, int timeoutMs, bool value, CancellationToken)
418+
// returns Task of Response of SmartSwitchInfo; Response.IsSuccess indicates the outcome.
419+
var response = await _rustPlus.StrobeSmartSwitchAsync(entityId, timeoutMs, value, timeoutCts.Token)
420+
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
421+
return response.IsSuccess;
422+
}
423+
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
424+
{
425+
return false;
426+
}
427+
#pragma warning disable CA1031 // Broad catch: any strobe failure maps to false; never surface a token/secret.
428+
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
429+
#pragma warning restore CA1031
430+
{
431+
LogQueryFailed(_logger, ex);
432+
return false;
433+
}
434+
}
435+
323436
public async Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(
324437
TimeSpan timeout,
325438
CancellationToken cancellationToken = default)
@@ -443,6 +556,7 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
443556
public async ValueTask DisposeAsync()
444557
{
445558
_rustPlus.OnTeamChatReceived -= OnTeamChatReceived;
559+
_rustPlus.OnSmartSwitchTriggered -= OnSmartSwitchTriggered;
446560
try
447561
{
448562
// CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1.
@@ -457,6 +571,9 @@ public async ValueTask DisposeAsync()
457571
}
458572
}
459573

574+
private void OnSmartSwitchTriggered(object? sender, RustPlusApi.Data.Events.SmartSwitchEventArg e) =>
575+
SmartSwitchTriggered?.Invoke(this, e.Id);
576+
460577
private static void AddMarkers<TMarker>(
461578
List<MapMarkerSnapshot> into,
462579
IReadOnlyDictionary<ulong, TMarker> source,

0 commit comments

Comments
 (0)