Skip to content

Commit 7aa71ab

Browse files
HandyS11claude
andcommitted
feat(switches): hosted service + AddSwitches DI + Host wiring (4a end-to-end)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1e74b0a commit 7aa71ab

29 files changed

Lines changed: 524 additions & 99 deletions

File tree

src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
6262
/// <param name="entityId">The in-game smart-switch entity id.</param>
6363
/// <param name="cancellationToken">A cancellation token.</param>
6464
/// <returns>True/false for on/off, or null when unavailable.</returns>
65-
Task<bool?> GetSmartSwitchStateAsync(ulong guildId, Guid serverId, ulong entityId, CancellationToken cancellationToken);
65+
Task<bool?> GetSmartSwitchStateAsync(ulong guildId,
66+
Guid serverId,
67+
ulong entityId,
68+
CancellationToken cancellationToken);
6669

6770
/// <summary>Sets a smart switch on/off; returns false when there is no live socket or the call fails.</summary>
6871
/// <param name="guildId">The owning guild snowflake.</param>
@@ -71,7 +74,11 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
7174
/// <param name="value">True to turn on, false to turn off.</param>
7275
/// <param name="cancellationToken">A cancellation token.</param>
7376
/// <returns>True on success; false when unavailable or the call fails.</returns>
74-
Task<bool> SetSmartSwitchAsync(ulong guildId, Guid serverId, ulong entityId, bool value, CancellationToken cancellationToken);
77+
Task<bool> SetSmartSwitchAsync(ulong guildId,
78+
Guid serverId,
79+
ulong entityId,
80+
bool value,
81+
CancellationToken cancellationToken);
7582

7683
/// <summary>Strobes a smart switch; returns false when there is no live socket or the call fails.</summary>
7784
/// <param name="guildId">The owning guild snowflake.</param>
@@ -81,5 +88,10 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
8188
/// <param name="value">The terminal value after strobing.</param>
8289
/// <param name="cancellationToken">A cancellation token.</param>
8390
/// <returns>True on success; false when unavailable or the call fails.</returns>
84-
Task<bool> StrobeSmartSwitchAsync(ulong guildId, Guid serverId, ulong entityId, int timeoutMs, bool value, CancellationToken cancellationToken);
91+
Task<bool> StrobeSmartSwitchAsync(ulong guildId,
92+
Guid serverId,
93+
ulong entityId,
94+
int timeoutMs,
95+
bool value,
96+
CancellationToken cancellationToken);
8597
}

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ internal interface IRustServerConnection : IAsyncDisposable
6060
/// <param name="timeout">How long to wait for the response.</param>
6161
/// <param name="cancellationToken">A cancellation token.</param>
6262
/// <returns>True on success; false on failure/timeout.</returns>
63-
Task<bool> SetSmartSwitchValueAsync(ulong entityId, bool value, TimeSpan timeout, CancellationToken cancellationToken);
63+
Task<bool> SetSmartSwitchValueAsync(ulong entityId,
64+
bool value,
65+
TimeSpan timeout,
66+
CancellationToken cancellationToken);
6467

6568
/// <summary>Strobes a smart switch; returns true on success, false on failure/timeout.</summary>
6669
/// <param name="entityId">The in-game smart-switch entity id.</param>
@@ -69,7 +72,11 @@ internal interface IRustServerConnection : IAsyncDisposable
6972
/// <param name="timeout">How long to wait for the response.</param>
7073
/// <param name="cancellationToken">A cancellation token.</param>
7174
/// <returns>True on success; false on failure/timeout.</returns>
72-
Task<bool> StrobeSmartSwitchAsync(ulong entityId, int timeoutMs, bool value, TimeSpan timeout, CancellationToken cancellationToken);
75+
Task<bool> StrobeSmartSwitchAsync(ulong entityId,
76+
int timeoutMs,
77+
bool value,
78+
TimeSpan timeout,
79+
CancellationToken cancellationToken);
7380

7481
/// <summary>Polls the current map markers the bot tracks (cargo ship, patrol helicopter, chinook), for diffing by id. Throws on failure.</summary>
7582
/// <param name="timeout">How long to wait for the response.</param>

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

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,22 @@ 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, TimeSpan timeout, CancellationToken cancellationToken) =>
51+
public Task<bool?> GetSmartSwitchInfoAsync(ulong entityId,
52+
TimeSpan timeout,
53+
CancellationToken cancellationToken) =>
5254
Task.FromResult<bool?>(null);
5355

54-
public Task<bool> SetSmartSwitchValueAsync(ulong entityId, bool value, TimeSpan timeout, CancellationToken cancellationToken) =>
56+
public Task<bool> SetSmartSwitchValueAsync(ulong entityId,
57+
bool value,
58+
TimeSpan timeout,
59+
CancellationToken cancellationToken) =>
5560
Task.FromResult(false);
5661

57-
public Task<bool> StrobeSmartSwitchAsync(ulong entityId, int timeoutMs, bool value, TimeSpan timeout, CancellationToken cancellationToken) =>
62+
public Task<bool> StrobeSmartSwitchAsync(ulong entityId,
63+
int timeoutMs,
64+
bool value,
65+
TimeSpan timeout,
66+
CancellationToken cancellationToken) =>
5867
Task.FromResult(false);
5968

6069
public Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(TimeSpan timeout,
@@ -338,10 +347,9 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,
338347

339348
public event EventHandler<ulong>? SmartSwitchTriggered;
340349

341-
private void OnSmartSwitchTriggered(object? sender, RustPlusApi.Data.Events.SmartSwitchEventArg e) =>
342-
SmartSwitchTriggered?.Invoke(this, e.Id);
343-
344-
public async Task<bool?> GetSmartSwitchInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken)
350+
public async Task<bool?> GetSmartSwitchInfoAsync(ulong entityId,
351+
TimeSpan timeout,
352+
CancellationToken cancellationToken)
345353
{
346354
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
347355
timeoutCts.CancelAfter(timeout);
@@ -368,7 +376,10 @@ private void OnSmartSwitchTriggered(object? sender, RustPlusApi.Data.Events.Smar
368376
}
369377
}
370378

371-
public async Task<bool> SetSmartSwitchValueAsync(ulong entityId, bool value, TimeSpan timeout, CancellationToken cancellationToken)
379+
public async Task<bool> SetSmartSwitchValueAsync(ulong entityId,
380+
bool value,
381+
TimeSpan timeout,
382+
CancellationToken cancellationToken)
372383
{
373384
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
374385
timeoutCts.CancelAfter(timeout);
@@ -393,7 +404,11 @@ public async Task<bool> SetSmartSwitchValueAsync(ulong entityId, bool value, Tim
393404
}
394405
}
395406

396-
public async Task<bool> StrobeSmartSwitchAsync(ulong entityId, int timeoutMs, bool value, TimeSpan timeout, CancellationToken cancellationToken)
407+
public async Task<bool> StrobeSmartSwitchAsync(ulong entityId,
408+
int timeoutMs,
409+
bool value,
410+
TimeSpan timeout,
411+
CancellationToken cancellationToken)
397412
{
398413
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
399414
timeoutCts.CancelAfter(timeout);
@@ -556,6 +571,9 @@ public async ValueTask DisposeAsync()
556571
}
557572
}
558573

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

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,10 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
247247

248248
/// <inheritdoc />
249249
public async Task<bool?> GetSmartSwitchStateAsync(
250-
ulong guildId, Guid serverId, ulong entityId, CancellationToken cancellationToken)
250+
ulong guildId,
251+
Guid serverId,
252+
ulong entityId,
253+
CancellationToken cancellationToken)
251254
{
252255
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
253256
{
@@ -260,7 +263,11 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
260263

261264
/// <inheritdoc />
262265
public async Task<bool> SetSmartSwitchAsync(
263-
ulong guildId, Guid serverId, ulong entityId, bool value, CancellationToken cancellationToken)
266+
ulong guildId,
267+
Guid serverId,
268+
ulong entityId,
269+
bool value,
270+
CancellationToken cancellationToken)
264271
{
265272
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
266273
{
@@ -274,7 +281,12 @@ public async Task<bool> SetSmartSwitchAsync(
274281

275282
/// <inheritdoc />
276283
public async Task<bool> StrobeSmartSwitchAsync(
277-
ulong guildId, Guid serverId, ulong entityId, int timeoutMs, bool value, CancellationToken cancellationToken)
284+
ulong guildId,
285+
Guid serverId,
286+
ulong entityId,
287+
int timeoutMs,
288+
bool value,
289+
CancellationToken cancellationToken)
278290
{
279291
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
280292
{
@@ -889,11 +901,15 @@ await eventBus.PublishAsync(
889901
private static partial void LogSwitchListFailed(ILogger logger, Exception exception, Guid serverId);
890902

891903
[LoggerMessage(Level = LogLevel.Warning, Message = "Priming smart switch {EntityId} on server {ServerId} failed.")]
892-
private static partial void LogSwitchPrimeFailed(ILogger logger, Exception exception, ulong entityId, Guid serverId);
904+
private static partial void
905+
LogSwitchPrimeFailed(ILogger logger, Exception exception, ulong entityId, Guid serverId);
893906

894907
[LoggerMessage(Level = LogLevel.Warning,
895908
Message = "Publishing a smart-switch state for entity {EntityId} on server {ServerId} failed.")]
896-
private static partial void LogSwitchPublishFailed(ILogger logger, Exception exception, ulong entityId, Guid serverId);
909+
private static partial void LogSwitchPublishFailed(ILogger logger,
910+
Exception exception,
911+
ulong entityId,
912+
Guid serverId);
897913

898914
private TimeSpan NextDelay(TimeSpan delay) =>
899915
delay < _options.MaxRetryDelay

src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ await eventBus.PublishAsync(new ServerRegisteredEvent(guildId, server.Id), cance
5252
}
5353

5454
private async Task HandleEntityAsync(
55-
ulong guildId, PairingNotification notification, CancellationToken cancellationToken)
55+
ulong guildId,
56+
PairingNotification notification,
57+
CancellationToken cancellationToken)
5658
{
5759
var server = await servers
5860
.GetByFacepunchServerIdAsync(guildId, notification.FacepunchServerId, cancellationToken)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using Microsoft.Extensions.Hosting;
2+
using Microsoft.Extensions.Logging;
3+
using RustPlusBot.Abstractions.Events;
4+
using RustPlusBot.Features.Switches.Pairing;
5+
using RustPlusBot.Features.Switches.Relaying;
6+
7+
namespace RustPlusBot.Features.Switches.Hosting;
8+
9+
/// <summary>Runs the switch-pairing loop and the switch-state/connection-status relay loop.</summary>
10+
/// <param name="eventBus">The in-process event bus.</param>
11+
/// <param name="coordinator">Handles paired switches.</param>
12+
/// <param name="relay">Re-renders switches on state/connection changes.</param>
13+
/// <param name="logger">The logger.</param>
14+
internal sealed partial class SwitchesHostedService(
15+
IEventBus eventBus,
16+
SwitchPairingCoordinator coordinator,
17+
SwitchStateRelay relay,
18+
ILogger<SwitchesHostedService> logger) : IHostedService, IDisposable
19+
{
20+
private readonly CancellationTokenSource _cts = new();
21+
private Task? _pairedLoop;
22+
private Task? _stateLoop;
23+
private Task? _statusLoop;
24+
25+
/// <inheritdoc />
26+
public void Dispose() => _cts.Dispose();
27+
28+
/// <inheritdoc />
29+
public Task StartAsync(CancellationToken cancellationToken)
30+
{
31+
_pairedLoop = Task.Run(() => ConsumePairedAsync(_cts.Token), CancellationToken.None);
32+
_stateLoop = Task.Run(() => ConsumeStateAsync(_cts.Token), CancellationToken.None);
33+
_statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None);
34+
return Task.CompletedTask;
35+
}
36+
37+
/// <inheritdoc />
38+
public async Task StopAsync(CancellationToken cancellationToken)
39+
{
40+
await _cts.CancelAsync().ConfigureAwait(false);
41+
foreach (var loop in new[]
42+
{
43+
_pairedLoop, _stateLoop, _statusLoop
44+
}.Where(t => t is not null))
45+
{
46+
try
47+
{
48+
#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop.
49+
await loop!.ConfigureAwait(false);
50+
#pragma warning restore VSTHRD003
51+
}
52+
catch (OperationCanceledException)
53+
{
54+
// Expected on shutdown.
55+
}
56+
}
57+
}
58+
59+
private async Task ConsumePairedAsync(CancellationToken cancellationToken)
60+
{
61+
try
62+
{
63+
await foreach (var evt in eventBus.SubscribeAsync<SwitchPairedEvent>(cancellationToken)
64+
.ConfigureAwait(false))
65+
{
66+
await coordinator.HandlePairedAsync(evt, cancellationToken).ConfigureAwait(false);
67+
}
68+
}
69+
catch (OperationCanceledException)
70+
{
71+
// Shutting down.
72+
}
73+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
74+
catch (Exception ex)
75+
#pragma warning restore CA1031
76+
{
77+
LogPairedLoopFaulted(logger, ex);
78+
}
79+
}
80+
81+
private async Task ConsumeStateAsync(CancellationToken cancellationToken)
82+
{
83+
try
84+
{
85+
await foreach (var evt in eventBus.SubscribeAsync<SwitchStateChangedEvent>(cancellationToken)
86+
.ConfigureAwait(false))
87+
{
88+
await relay.HandleStateChangedAsync(evt, cancellationToken).ConfigureAwait(false);
89+
}
90+
}
91+
catch (OperationCanceledException)
92+
{
93+
// Shutting down.
94+
}
95+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
96+
catch (Exception ex)
97+
#pragma warning restore CA1031
98+
{
99+
LogStateLoopFaulted(logger, ex);
100+
}
101+
}
102+
103+
private async Task ConsumeStatusAsync(CancellationToken cancellationToken)
104+
{
105+
try
106+
{
107+
await foreach (var evt in eventBus.SubscribeAsync<ConnectionStatusChangedEvent>(cancellationToken)
108+
.ConfigureAwait(false))
109+
{
110+
await relay.HandleConnectionStatusAsync(evt, cancellationToken).ConfigureAwait(false);
111+
}
112+
}
113+
catch (OperationCanceledException)
114+
{
115+
// Shutting down.
116+
}
117+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
118+
catch (Exception ex)
119+
#pragma warning restore CA1031
120+
{
121+
LogStatusLoopFaulted(logger, ex);
122+
}
123+
}
124+
125+
[LoggerMessage(Level = LogLevel.Error, Message = "Switch pairing loop faulted.")]
126+
private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception);
127+
128+
[LoggerMessage(Level = LogLevel.Error, Message = "Switch state relay loop faulted.")]
129+
private static partial void LogStateLoopFaulted(ILogger logger, Exception exception);
130+
131+
[LoggerMessage(Level = LogLevel.Error, Message = "Switch connection-status relay loop faulted.")]
132+
private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception);
133+
}

src/RustPlusBot.Features.Switches/Modules/SwitchComponentModule.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ public async Task RenameSubmitAsync(string tail, SwitchRenameModal modal)
135135
return;
136136
}
137137

138-
var name = string.IsNullOrWhiteSpace(modal.Name) ? "Switch " + entityId.ToString(CultureInfo.InvariantCulture)
138+
var name = string.IsNullOrWhiteSpace(modal.Name)
139+
? "Switch " + entityId.ToString(CultureInfo.InvariantCulture)
139140
: modal.Name.Trim();
140141
await DeferAsync(ephemeral: true).ConfigureAwait(false);
141142

src/RustPlusBot.Features.Switches/Pairing/SwitchPairingCoordinator.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ public async Task HandlePairedAsync(SwitchPairedEvent evt, CancellationToken can
6565
/// <param name="cancellationToken">A token to cancel the operation.</param>
6666
/// <returns>True when the switch was persisted; false when it was already managed (race).</returns>
6767
public async Task<bool> TryAcceptAsync(
68-
ulong guildId, Guid serverId, ulong entityId, ulong acceptingUserId, CancellationToken cancellationToken)
68+
ulong guildId,
69+
Guid serverId,
70+
ulong entityId,
71+
ulong acceptingUserId,
72+
CancellationToken cancellationToken)
6973
{
7074
if (await ExistsAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false))
7175
{

src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ internal sealed partial class DiscordSwitchChannelPoster(
2121
{
2222
try
2323
{
24-
var options = new global::Discord.RequestOptions { CancelToken = cancellationToken };
24+
var options = new global::Discord.RequestOptions
25+
{
26+
CancelToken = cancellationToken
27+
};
2528
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
2629
is not global::Discord.ITextChannel channel)
2730
{
@@ -78,5 +81,6 @@ await userMessage.ModifyAsync(m =>
7881

7982
[LoggerMessage(Level = LogLevel.Debug,
8083
Message = "Switch embed {MessageId} in channel {ChannelId} was deleted; reposting.")]
81-
private static partial void LogMessageMissing(ILogger logger, Exception exception, ulong channelId, ulong messageId);
84+
private static partial void
85+
LogMessageMissing(ILogger logger, Exception exception, ulong channelId, ulong messageId);
8286
}

0 commit comments

Comments
 (0)