Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4cd2905
chore(deps): bump RustPlusApi/RustPlusApi.Fcm to 2.0.0-beta.2 for 4a
HandyS11 Jun 19, 2026
2a45be3
feat(switches): add SwitchPairedEvent and SwitchStateChangedEvent
HandyS11 Jun 19, 2026
3a2818a
feat(switches): add SmartSwitch entity, config, and SmartSwitches mig…
HandyS11 Jun 19, 2026
a3694ee
feat(switches): add ISwitchStore/SwitchStore with CRUD over SmartSwitch
HandyS11 Jun 19, 2026
03d3fb8
feat(switches): add #switches channel spec, EN/FR name, and channel l…
HandyS11 Jun 19, 2026
0e9f72b
feat(switches): add smart-switch read/control/event to the connection…
HandyS11 Jun 19, 2026
a852ba8
feat(switches): supervisor smart-switch query, connect priming, and t…
HandyS11 Jun 19, 2026
ef16cbb
feat(switches): add ServerService.GetByEndpointAsync lookup-only path
HandyS11 Jun 19, 2026
f45714b
feat(switches): route FCM entity pairings to SwitchPairedEvent via Fa…
HandyS11 Jun 19, 2026
bcb45dc
feat(switches): scaffold Features.Switches project + EN/FR localizer …
HandyS11 Jun 19, 2026
4decb7b
feat(switches): add SwitchEmbedRenderer (embed, control row, pairing …
HandyS11 Jun 19, 2026
deb0c7f
feat(switches): add DiscordSwitchChannelPoster (edit-by-id with self-…
HandyS11 Jun 19, 2026
482877b
feat(switches): add SwitchPairingCoordinator (prompt, accept, dedupe)
HandyS11 Jun 19, 2026
4867fe7
feat(switches): add SwitchStateRelay (state re-render + unreachable o…
HandyS11 Jun 19, 2026
1e74b0a
feat(switches): add SwitchComponentModule + rename modal (thin intera…
HandyS11 Jun 19, 2026
7aa71ab
feat(switches): hosted service + AddSwitches DI + Host wiring (4a end…
HandyS11 Jun 19, 2026
35626cd
fix(switches): address PR review — guard empty Facepunch GUID, idempo…
HandyS11 Jun 19, 2026
807d1a3
feat(gitignore): add .superpowers directory to ignore list
HandyS11 Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,8 @@ docs/obj/
docs/superpowers/
docs/product/

.superpowers/

# Stryker mutation testing output
StrykerOutput/

Expand Down
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.1" />
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.1" />
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.2" />
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.2" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
<!-- Pinned to override the transitive 2.1.11 pulled by Microsoft.EntityFrameworkCore.Sqlite,
Expand Down
2 changes: 2 additions & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
<Project Path="src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj" />
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
</Folder>
Expand All @@ -21,6 +22,7 @@
<Project Path="tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />
Expand Down
39 changes: 39 additions & 0 deletions src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,43 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
ulong guildId,
Guid serverId,
CancellationToken cancellationToken);

/// <summary>Reads a smart switch's on/off state, or null when there is no live socket or the call fails.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="entityId">The in-game smart-switch entity id.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>True/false for on/off, or null when unavailable.</returns>
Task<bool?> GetSmartSwitchStateAsync(ulong guildId,
Guid serverId,
ulong entityId,
CancellationToken cancellationToken);

/// <summary>Sets a smart switch on/off; returns false when there is no live socket or the call fails.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="entityId">The in-game smart-switch entity id.</param>
/// <param name="value">True to turn on, false to turn off.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>True on success; false when unavailable or the call fails.</returns>
Task<bool> SetSmartSwitchAsync(ulong guildId,
Guid serverId,
ulong entityId,
bool value,
CancellationToken cancellationToken);

/// <summary>Strobes a smart switch; returns false when there is no live socket or the call fails.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="entityId">The in-game smart-switch entity id.</param>
/// <param name="timeoutMs">The in-game strobe duration in milliseconds.</param>
/// <param name="value">The terminal value after strobing.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>True on success; false when unavailable or the call fails.</returns>
Task<bool> StrobeSmartSwitchAsync(ulong guildId,
Guid serverId,
ulong entityId,
int timeoutMs,
bool value,
CancellationToken cancellationToken);
}
7 changes: 7 additions & 0 deletions src/RustPlusBot.Abstractions/Events/SwitchPairedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>Raised when a Smart Switch is paired in-game and resolved to a known server (awaiting user validation).</summary>
/// <param name="GuildId">The owning Discord guild snowflake.</param>
/// <param name="ServerId">The local RustServer id the entity belongs to.</param>
/// <param name="EntityId">The in-game smart-switch entity id.</param>
public sealed record SwitchPairedEvent(ulong GuildId, Guid ServerId, ulong EntityId);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>Raised when a Smart Switch's live on/off state is observed (prime or trigger).</summary>
/// <param name="GuildId">The owning Discord guild snowflake.</param>
/// <param name="ServerId">The local RustServer id the entity belongs to.</param>
/// <param name="EntityId">The in-game smart-switch entity id.</param>
/// <param name="IsActive">True when the switch is on.</param>
public sealed record SwitchStateChangedEvent(ulong GuildId, Guid ServerId, ulong EntityId, bool IsActive);
3 changes: 3 additions & 0 deletions src/RustPlusBot.Domain/Servers/RustServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ public sealed class RustServer

/// <summary>The Discord user who added this server.</summary>
public ulong AddedByUserId { get; set; }

/// <summary>The Facepunch server GUID from FCM pairings, backfilled on server pairing; null until first seen. Used to attribute entity pairings.</summary>
public Guid? FacepunchServerId { get; set; }
}
32 changes: 32 additions & 0 deletions src/RustPlusBot.Domain/Switches/SmartSwitch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace RustPlusBot.Domain.Switches;

/// <summary>A paired Smart Switch the bot manages, surviving restarts. Guild- and server-scoped.</summary>
public sealed class SmartSwitch
{
/// <summary>Surrogate primary key.</summary>
public Guid Id { get; set; } = Guid.NewGuid();

/// <summary>The owning Discord guild snowflake.</summary>
public ulong GuildId { get; set; }

/// <summary>The server this switch belongs to (FK to RustServer, cascade delete).</summary>
public Guid ServerId { get; set; }

/// <summary>The in-game smart-switch entity id.</summary>
public ulong EntityId { get; set; }

/// <summary>User-facing label; defaults to a generated "Switch &lt;EntityId&gt;" (the FCM event carries no name).</summary>
public string Name { get; set; } = string.Empty;

/// <summary>The Discord message id of this switch's embed, or null until first posted.</summary>
public ulong? MessageId { get; set; }

/// <summary>The Discord user who accepted (validated) the pairing.</summary>
public ulong PairedByUserId { get; set; }

/// <summary>The last observed on/off state.</summary>
public bool LastIsActive { get; set; }

/// <summary>When the switch was accepted (UTC).</summary>
public DateTimeOffset CreatedUtc { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,37 @@ internal interface IRustServerConnection : IAsyncDisposable
/// <returns>True if the promotion succeeded; false on failure/timeout.</returns>
Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken);

/// <summary>Reads a smart switch's on/off state, or null on failure/timeout. Also primes the socket's interest in the entity.</summary>
/// <param name="entityId">The in-game smart-switch entity id.</param>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>True/false for on/off, or null on failure/timeout.</returns>
Task<bool?> GetSmartSwitchInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken);

/// <summary>Sets a smart switch on/off; returns true on success, false on failure/timeout.</summary>
/// <param name="entityId">The in-game smart-switch entity id.</param>
/// <param name="value">True to turn on, false to turn off.</param>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>True on success; false on failure/timeout.</returns>
Task<bool> SetSmartSwitchValueAsync(ulong entityId,
bool value,
TimeSpan timeout,
CancellationToken cancellationToken);

/// <summary>Strobes a smart switch; returns true on success, false on failure/timeout.</summary>
/// <param name="entityId">The in-game smart-switch entity id.</param>
/// <param name="timeoutMs">The in-game strobe duration in milliseconds.</param>
/// <param name="value">The terminal value after strobing.</param>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>True on success; false on failure/timeout.</returns>
Task<bool> StrobeSmartSwitchAsync(ulong entityId,
int timeoutMs,
bool value,
TimeSpan timeout,
CancellationToken cancellationToken);

/// <summary>Polls the current map markers the bot tracks (cargo ship, patrol helicopter, chinook), for diffing by id. Throws on failure.</summary>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
Expand Down Expand Up @@ -75,4 +106,7 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,

/// <summary>Raised for every in-game team chat line received on this socket.</summary>
event EventHandler<TeamChatLine>? TeamMessageReceived;

/// <summary>Raised when a smart switch's state changes in-game; carries the entity id.</summary>
event EventHandler<ulong>? SmartSwitchTriggered;
}
117 changes: 117 additions & 0 deletions src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT
public Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) =>
Task.FromResult(false);

public Task<bool?> GetSmartSwitchInfoAsync(ulong entityId,
TimeSpan timeout,
CancellationToken cancellationToken) =>
Task.FromResult<bool?>(null);

public Task<bool> SetSmartSwitchValueAsync(ulong entityId,
bool value,
TimeSpan timeout,
CancellationToken cancellationToken) =>
Task.FromResult(false);

public Task<bool> StrobeSmartSwitchAsync(ulong entityId,
int timeoutMs,
bool value,
TimeSpan timeout,
CancellationToken cancellationToken) =>
Task.FromResult(false);

public Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(TimeSpan timeout,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<MapMarkerSnapshot>>([]);
Expand All @@ -69,6 +87,12 @@ public event EventHandler<TeamChatLine>? TeamMessageReceived
remove { _ = value; }
}

public event EventHandler<ulong>? SmartSwitchTriggered
{
add { _ = value; }
remove { _ = value; }
}

public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

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

public async Task<SocketConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken)
Expand Down Expand Up @@ -320,6 +345,94 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,
}
}

public event EventHandler<ulong>? SmartSwitchTriggered;

public async Task<bool?> GetSmartSwitchInfoAsync(ulong entityId,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
// CONFIRMED (2.0.0-beta.2): GetSmartSwitchInfoAsync(ulong, CancellationToken) returns
// Task of Response of SmartSwitchInfo; Response.IsSuccess and Response.Data are the accessors and
// SmartSwitchInfo.IsActive is a bool. The call also primes the socket's interest in this
// entity, so OnSmartSwitchTriggered fires for it thereafter.
var response = await _rustPlus.GetSmartSwitchInfoAsync(entityId, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
return response.IsSuccess && response.Data is { } info ? info.IsActive : null;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
#pragma warning disable CA1031 // Broad catch: any switch-info failure maps to null; never surface a token/secret.
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
#pragma warning restore CA1031
{
LogQueryFailed(_logger, ex);
return null;
}
}

public async Task<bool> SetSmartSwitchValueAsync(ulong entityId,
bool value,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
// CONFIRMED (2.0.0-beta.2): SetSmartSwitchValueAsync(ulong, bool, CancellationToken) returns
// Task of Response of SmartSwitchInfo; Response.IsSuccess indicates the outcome.
var response = await _rustPlus.SetSmartSwitchValueAsync(entityId, value, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
return response.IsSuccess;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return false;
}
#pragma warning disable CA1031 // Broad catch: any set failure maps to false; never surface a token/secret.
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
#pragma warning restore CA1031
{
LogQueryFailed(_logger, ex);
return false;
}
}

public async Task<bool> StrobeSmartSwitchAsync(ulong entityId,
int timeoutMs,
bool value,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
// CONFIRMED (2.0.0-beta.2): StrobeSmartSwitchAsync(ulong, int timeoutMs, bool value, CancellationToken)
// returns Task of Response of SmartSwitchInfo; Response.IsSuccess indicates the outcome.
var response = await _rustPlus.StrobeSmartSwitchAsync(entityId, timeoutMs, value, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
return response.IsSuccess;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return false;
}
#pragma warning disable CA1031 // Broad catch: any strobe failure maps to false; never surface a token/secret.
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
#pragma warning restore CA1031
{
LogQueryFailed(_logger, ex);
return false;
}
}

public async Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(
TimeSpan timeout,
CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -443,6 +556,7 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
public async ValueTask DisposeAsync()
{
_rustPlus.OnTeamChatReceived -= OnTeamChatReceived;
_rustPlus.OnSmartSwitchTriggered -= OnSmartSwitchTriggered;
try
{
// CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1.
Expand All @@ -457,6 +571,9 @@ public async ValueTask DisposeAsync()
}
}

private void OnSmartSwitchTriggered(object? sender, RustPlusApi.Data.Events.SmartSwitchEventArg e) =>
SmartSwitchTriggered?.Invoke(this, e.Id);

private static void AddMarkers<TMarker>(
List<MapMarkerSnapshot> into,
IReadOnlyDictionary<ulong, TMarker> source,
Expand Down
Loading
Loading