Skip to content

Commit d551633

Browse files
HandyS11claude
andcommitted
feat(switches): inline per-device reachability (relay + renderer + EN/FR)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bd7c330 commit d551633

8 files changed

Lines changed: 205 additions & 11 deletions

File tree

src/RustPlusBot.Features.Switches/Hosting/SwitchesHostedService.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ internal sealed partial class SwitchesHostedService(
2020
private readonly CancellationTokenSource _cts = new();
2121
private Task? _deviceLoop;
2222
private Task? _pairedLoop;
23+
private Task? _reachabilityLoop;
2324
private Task? _stateLoop;
2425
private Task? _statusLoop;
2526

@@ -33,6 +34,7 @@ public Task StartAsync(CancellationToken cancellationToken)
3334
_stateLoop = Task.Run(() => ConsumeStateAsync(_cts.Token), CancellationToken.None);
3435
_statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None);
3536
_deviceLoop = Task.Run(() => ConsumeDeviceTriggeredAsync(_cts.Token), CancellationToken.None);
37+
_reachabilityLoop = Task.Run(() => ConsumeReachabilityChangedAsync(_cts.Token), CancellationToken.None);
3638
return Task.CompletedTask;
3739
}
3840

@@ -42,7 +44,7 @@ public async Task StopAsync(CancellationToken cancellationToken)
4244
await _cts.CancelAsync().ConfigureAwait(false);
4345
foreach (var loop in new[]
4446
{
45-
_pairedLoop, _stateLoop, _statusLoop, _deviceLoop
47+
_pairedLoop, _stateLoop, _statusLoop, _deviceLoop, _reachabilityLoop
4648
}.Where(t => t is not null))
4749
{
4850
try
@@ -146,6 +148,28 @@ private async Task ConsumeDeviceTriggeredAsync(CancellationToken cancellationTok
146148
}
147149
}
148150

151+
private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellationToken)
152+
{
153+
try
154+
{
155+
await foreach (var evt in eventBus.SubscribeAsync<DeviceReachabilityChangedEvent>(cancellationToken)
156+
.ConfigureAwait(false))
157+
{
158+
await relay.HandleReachabilityChangedAsync(evt, cancellationToken).ConfigureAwait(false);
159+
}
160+
}
161+
catch (OperationCanceledException)
162+
{
163+
// Shutting down.
164+
}
165+
#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
166+
catch (Exception ex)
167+
#pragma warning restore CA1031
168+
{
169+
LogReachabilityLoopFaulted(logger, ex);
170+
}
171+
}
172+
149173
[LoggerMessage(Level = LogLevel.Error, Message = "Switch device-triggered relay loop faulted.")]
150174
private static partial void LogDeviceLoopFaulted(ILogger logger, Exception exception);
151175

@@ -157,4 +181,7 @@ private async Task ConsumeDeviceTriggeredAsync(CancellationToken cancellationTok
157181

158182
[LoggerMessage(Level = LogLevel.Error, Message = "Switch connection-status relay loop faulted.")]
159183
private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception);
184+
185+
[LoggerMessage(Level = LogLevel.Error, Message = "Switch reachability relay loop faulted.")]
186+
private static partial void LogReachabilityLoopFaulted(ILogger logger, Exception exception);
160187
}

src/RustPlusBot.Features.Switches/Relaying/SwitchStateRelay.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Abstractions.Connections;
23
using RustPlusBot.Abstractions.Events;
34
using RustPlusBot.Domain.Connections;
45
using RustPlusBot.Domain.Switches;
@@ -82,6 +83,42 @@ await RenderAsync(store, sw, evt.IsActive, evt.GuildId, evt.ServerId, culture, c
8283
}
8384
}
8485

86+
/// <summary>Handles a per-device reachability change: ignore foreign entities, else persist + re-render.</summary>
87+
/// <param name="evt">The device-reachability-changed event.</param>
88+
/// <param name="cancellationToken">A cancellation token.</param>
89+
/// <returns>A task that completes when the embed has been re-rendered (or the id was ignored).</returns>
90+
public async Task HandleReachabilityChangedAsync(
91+
DeviceReachabilityChangedEvent evt,
92+
CancellationToken cancellationToken)
93+
{
94+
ArgumentNullException.ThrowIfNull(evt);
95+
var scope = scopeFactory.CreateAsyncScope();
96+
await using (scope.ConfigureAwait(false))
97+
{
98+
var store = scope.ServiceProvider.GetRequiredService<ISwitchStore>();
99+
if (!await store.ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken)
100+
.ConfigureAwait(false))
101+
{
102+
return; // not a switch this relay manages — ignore.
103+
}
104+
105+
await store.SetReachabilityAsync(evt.GuildId, evt.ServerId, evt.EntityId, evt.Reachability,
106+
cancellationToken)
107+
.ConfigureAwait(false);
108+
var sw = await store.GetAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken)
109+
.ConfigureAwait(false);
110+
if (sw is null)
111+
{
112+
return;
113+
}
114+
115+
var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken)
116+
.ConfigureAwait(false);
117+
await RenderAsync(store, sw, sw.LastIsActive, evt.GuildId, evt.ServerId, culture, cancellationToken)
118+
.ConfigureAwait(false);
119+
}
120+
}
121+
85122
/// <summary>Handles a connection-status change: a non-Connected server marks its switch embeds unreachable.</summary>
86123
/// <param name="evt">The connection-status change.</param>
87124
/// <param name="cancellationToken">A cancellation token.</param>

src/RustPlusBot.Features.Switches/Rendering/SwitchEmbedRenderer.cs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Discord;
2+
using RustPlusBot.Abstractions.Connections;
23
using RustPlusBot.Domain.Switches;
34
using RustPlusBot.Localization;
45

@@ -16,13 +17,22 @@ internal sealed class SwitchEmbedRenderer(ILocalizer localizer)
1617
public (Embed Embed, MessageComponent Components) RenderSwitch(SmartSwitch sw, bool? isActive, string culture)
1718
{
1819
ArgumentNullException.ThrowIfNull(sw);
19-
var unreachable = isActive is null;
20-
var statusKey = isActive switch
20+
var reason = sw.Reachability;
21+
var blocked = reason is DeviceReachability.Removed or DeviceReachability.NoPrivilege;
22+
var serverDown = reason == DeviceReachability.Reachable && isActive is null;
23+
var statusKey = reason switch
2124
{
22-
true => "switch.status.on",
23-
false => "switch.status.off",
24-
null => "switch.status.unreachable",
25+
DeviceReachability.Removed => "switch.status.removed",
26+
DeviceReachability.NoPrivilege => "switch.status.noprivilege",
27+
DeviceReachability.NoResponse => "switch.status.noresponse",
28+
_ => isActive switch
29+
{
30+
true => "switch.status.on",
31+
false => "switch.status.off",
32+
null => "switch.status.unreachable",
33+
},
2534
};
35+
var controlsDisabled = blocked || serverDown;
2636

2737
var embed = new EmbedBuilder()
2838
.WithTitle(sw.Name)
@@ -33,13 +43,13 @@ internal sealed class SwitchEmbedRenderer(ILocalizer localizer)
3343
var tail = $"{sw.ServerId}:{sw.EntityId}";
3444
var components = new ComponentBuilder()
3545
.WithButton(localizer.Get("switch.button.on", culture), SwitchComponentIds.OnPrefix + tail,
36-
ButtonStyle.Success, disabled: unreachable || isActive == true)
46+
ButtonStyle.Success, disabled: controlsDisabled || isActive == true)
3747
.WithButton(localizer.Get("switch.button.off", culture), SwitchComponentIds.OffPrefix + tail,
38-
ButtonStyle.Secondary, disabled: unreachable || isActive == false)
48+
ButtonStyle.Secondary, disabled: controlsDisabled || isActive == false)
3949
.WithButton(localizer.Get("switch.button.strobe", culture), SwitchComponentIds.StrobePrefix + tail,
40-
ButtonStyle.Primary, disabled: unreachable)
50+
ButtonStyle.Primary, disabled: controlsDisabled)
4151
.WithButton(localizer.Get("switch.button.rename", culture), SwitchComponentIds.RenamePrefix + tail,
42-
ButtonStyle.Secondary, disabled: unreachable)
52+
ButtonStyle.Secondary, disabled: controlsDisabled)
4353
.Build();
4454

4555
return (embed, components);

src/RustPlusBot.Localization/Strings.fr.resx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,15 @@
693693
<data name="switch.status.on" xml:space="preserve">
694694
<value>⚡ ALLUMÉ</value>
695695
</data>
696+
<data name="switch.status.noprivilege" xml:space="preserve">
697+
<value>⛔ Aucun privilège de construction</value>
698+
</data>
699+
<data name="switch.status.noresponse" xml:space="preserve">
700+
<value>⚠️ Aucune réponse</value>
701+
</data>
702+
<data name="switch.status.removed" xml:space="preserve">
703+
<value>❌ Supprimé en jeu</value>
704+
</data>
696705
<data name="switch.status.unreachable" xml:space="preserve">
697706
<value>⚠️ Injoignable</value>
698707
</data>

src/RustPlusBot.Localization/Strings.resx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,15 @@
693693
<data name="switch.status.on" xml:space="preserve">
694694
<value>⚡ ON</value>
695695
</data>
696+
<data name="switch.status.noprivilege" xml:space="preserve">
697+
<value>⛔ No building privilege</value>
698+
</data>
699+
<data name="switch.status.noresponse" xml:space="preserve">
700+
<value>⚠️ No response</value>
701+
</data>
702+
<data name="switch.status.removed" xml:space="preserve">
703+
<value>❌ Removed in-game</value>
704+
</data>
696705
<data name="switch.status.unreachable" xml:space="preserve">
697706
<value>⚠️ Unreachable</value>
698707
</data>

tests/RustPlusBot.Features.Switches.Tests/SwitchEmbedRendererTests.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Discord;
2+
using RustPlusBot.Abstractions.Connections;
23
using RustPlusBot.Domain.Switches;
34
using RustPlusBot.Features.Switches.Rendering;
45
using RustPlusBot.Localization;
@@ -68,6 +69,61 @@ public void RenderSwitch_french_uses_french_status()
6869
Assert.Contains("ALLUMÉ", embed.Description ?? string.Empty, StringComparison.Ordinal);
6970
}
7071

72+
[Theory]
73+
[InlineData(DeviceReachability.Removed, "Removed in-game")]
74+
[InlineData(DeviceReachability.NoPrivilege, "No building privilege")]
75+
[InlineData(DeviceReachability.NoResponse, "No response")]
76+
public void RenderSwitch_NonReachable_ShowsReasonStatus(DeviceReachability reachability, string expectedText)
77+
{
78+
var sw = new SmartSwitch
79+
{
80+
GuildId = 10UL,
81+
ServerId = Guid.NewGuid(),
82+
EntityId = 42UL,
83+
Name = "Door",
84+
Reachability = reachability,
85+
};
86+
var (embed, _) = Create().RenderSwitch(sw, isActive: true, "en");
87+
Assert.Contains(expectedText, embed.Description ?? string.Empty, StringComparison.Ordinal);
88+
}
89+
90+
[Fact]
91+
public void RenderSwitch_NoResponse_keeps_controls_enabled()
92+
{
93+
var sw = new SmartSwitch
94+
{
95+
GuildId = 10UL,
96+
ServerId = Guid.NewGuid(),
97+
EntityId = 42UL,
98+
Name = "Door",
99+
Reachability = DeviceReachability.NoResponse,
100+
};
101+
var (_, components) = Create().RenderSwitch(sw, isActive: false, "en");
102+
var buttons = components.Components.OfType<ActionRowComponent>().SelectMany(r => r.Components)
103+
.OfType<ButtonComponent>().ToList();
104+
// NoResponse is transient — controls remain enabled
105+
Assert.False(buttons.All(b => b.IsDisabled));
106+
}
107+
108+
[Theory]
109+
[InlineData(DeviceReachability.Removed)]
110+
[InlineData(DeviceReachability.NoPrivilege)]
111+
public void RenderSwitch_BlockedReachability_disables_all_control_buttons(DeviceReachability reachability)
112+
{
113+
var sw = new SmartSwitch
114+
{
115+
GuildId = 10UL,
116+
ServerId = Guid.NewGuid(),
117+
EntityId = 42UL,
118+
Name = "Door",
119+
Reachability = reachability,
120+
};
121+
var (_, components) = Create().RenderSwitch(sw, isActive: true, "en");
122+
var buttons = components.Components.OfType<ActionRowComponent>().SelectMany(r => r.Components)
123+
.OfType<ButtonComponent>().ToList();
124+
Assert.All(buttons, b => Assert.True(b.IsDisabled));
125+
}
126+
71127
[Fact]
72128
public void RenderPrompt_carries_accept_and_dismiss_with_identity_tail()
73129
{

tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using NSubstitute;
3+
using RustPlusBot.Abstractions.Connections;
34
using RustPlusBot.Abstractions.Events;
45
using RustPlusBot.Domain.Connections;
56
using RustPlusBot.Domain.Switches;
@@ -154,6 +155,51 @@ await h.Poster.DidNotReceive().EnsureAsync(Arg.Any<ulong>(), Arg.Any<ulong?>(),
154155
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>());
155156
}
156157

158+
[Fact]
159+
public async Task ReachabilityChanged_ForeignEntity_IsIgnored()
160+
{
161+
var h = Create();
162+
var serverId = Guid.NewGuid();
163+
h.Store.ExistsAsync(10UL, serverId, 999UL, Arg.Any<CancellationToken>()).Returns(false);
164+
165+
await h.Relay.HandleReachabilityChangedAsync(
166+
new DeviceReachabilityChangedEvent(10UL, serverId, 999UL, DeviceReachability.Removed),
167+
CancellationToken.None);
168+
169+
await h.Store.DidNotReceive().SetReachabilityAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<ulong>(),
170+
Arg.Any<DeviceReachability>(), Arg.Any<CancellationToken>());
171+
await h.Poster.DidNotReceive().EnsureAsync(Arg.Any<ulong>(), Arg.Any<ulong?>(),
172+
Arg.Any<global::Discord.Embed>(),
173+
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>());
174+
}
175+
176+
[Fact]
177+
public async Task ReachabilityChanged_OwnedEntity_PersistsAndRenders()
178+
{
179+
var h = Create();
180+
var serverId = Guid.NewGuid();
181+
h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any<CancellationToken>()).Returns(true);
182+
h.Store.GetAsync(10UL, serverId, 42UL, Arg.Any<CancellationToken>())
183+
.Returns(new SmartSwitch
184+
{
185+
GuildId = 10UL,
186+
ServerId = serverId,
187+
EntityId = 42UL,
188+
Name = "Door",
189+
MessageId = 900UL,
190+
Reachability = DeviceReachability.Removed,
191+
});
192+
193+
await h.Relay.HandleReachabilityChangedAsync(
194+
new DeviceReachabilityChangedEvent(10UL, serverId, 42UL, DeviceReachability.Removed),
195+
CancellationToken.None);
196+
197+
await h.Store.Received(1).SetReachabilityAsync(10UL, serverId, 42UL, DeviceReachability.Removed,
198+
Arg.Any<CancellationToken>());
199+
await h.Poster.Received(1).EnsureAsync(777UL, 900UL, Arg.Any<global::Discord.Embed>(),
200+
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>());
201+
}
202+
157203
private sealed record Harness(
158204
SwitchStateRelay Relay,
159205
ISwitchStore Store,

tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,6 @@ public void English_covers_every_french_key()
4242
[Fact]
4343
public void Catalog_has_expected_key_count()
4444
{
45-
Assert.Equal(248, EnglishKeys().Count);
45+
Assert.Equal(251, EnglishKeys().Count);
4646
}
4747
}

0 commit comments

Comments
 (0)