Skip to content

Commit 4867fe7

Browse files
committed
feat(switches): add SwitchStateRelay (state re-render + unreachable on disconnect)
1 parent 482877b commit 4867fe7

2 files changed

Lines changed: 208 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Abstractions.Events;
3+
using RustPlusBot.Domain.Connections;
4+
using RustPlusBot.Domain.Switches;
5+
using RustPlusBot.Features.Switches.Posting;
6+
using RustPlusBot.Features.Switches.Rendering;
7+
using RustPlusBot.Features.Workspace.Locating;
8+
using RustPlusBot.Persistence.Connections;
9+
using RustPlusBot.Persistence.Switches;
10+
using RustPlusBot.Persistence.Workspace;
11+
12+
namespace RustPlusBot.Features.Switches.Relaying;
13+
14+
/// <summary>Keeps switch embeds in sync: live state changes re-render; a non-Connected server marks them unreachable.</summary>
15+
/// <param name="scopeFactory">Opens scopes for the scoped stores.</param>
16+
/// <param name="locator">Resolves the #switches channel id.</param>
17+
/// <param name="poster">Posts/edits switch embeds.</param>
18+
/// <param name="renderer">Renders switch embeds.</param>
19+
internal sealed class SwitchStateRelay(
20+
IServiceScopeFactory scopeFactory,
21+
ISwitchChannelLocator locator,
22+
ISwitchChannelPoster poster,
23+
SwitchEmbedRenderer renderer)
24+
{
25+
/// <summary>Handles a live state change: persist + re-render the switch's embed.</summary>
26+
/// <param name="evt">The switch state change.</param>
27+
/// <param name="cancellationToken">A cancellation token.</param>
28+
/// <returns>A task that completes when the embed has been re-rendered.</returns>
29+
public async Task HandleStateChangedAsync(SwitchStateChangedEvent evt, CancellationToken cancellationToken)
30+
{
31+
ArgumentNullException.ThrowIfNull(evt);
32+
var scope = scopeFactory.CreateAsyncScope();
33+
await using (scope.ConfigureAwait(false))
34+
{
35+
var store = scope.ServiceProvider.GetRequiredService<ISwitchStore>();
36+
await store.UpdateStateAsync(evt.GuildId, evt.ServerId, evt.EntityId, evt.IsActive, cancellationToken)
37+
.ConfigureAwait(false);
38+
var sw = await store.GetAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken)
39+
.ConfigureAwait(false);
40+
if (sw is null)
41+
{
42+
return;
43+
}
44+
45+
var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken)
46+
.ConfigureAwait(false);
47+
await RenderAsync(store, sw, evt.IsActive, evt.GuildId, evt.ServerId, culture, cancellationToken)
48+
.ConfigureAwait(false);
49+
}
50+
}
51+
52+
/// <summary>Handles a connection-status change: a non-Connected server marks its switch embeds unreachable.</summary>
53+
/// <param name="evt">The connection-status change.</param>
54+
/// <param name="cancellationToken">A cancellation token.</param>
55+
/// <returns>A task that completes when every affected embed has been re-rendered.</returns>
56+
public async Task HandleConnectionStatusAsync(
57+
ConnectionStatusChangedEvent evt, CancellationToken cancellationToken)
58+
{
59+
ArgumentNullException.ThrowIfNull(evt);
60+
var scope = scopeFactory.CreateAsyncScope();
61+
await using (scope.ConfigureAwait(false))
62+
{
63+
var connections = scope.ServiceProvider.GetRequiredService<IConnectionStore>();
64+
var state = await connections.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken)
65+
.ConfigureAwait(false);
66+
if (state is { Status: ConnectionStatus.Connected })
67+
{
68+
// The supervisor's prime path republishes real state on connect; nothing to do here.
69+
return;
70+
}
71+
72+
var store = scope.ServiceProvider.GetRequiredService<ISwitchStore>();
73+
var switches = await store.ListByServerAsync(evt.GuildId, evt.ServerId, cancellationToken)
74+
.ConfigureAwait(false);
75+
if (switches.Count == 0)
76+
{
77+
return;
78+
}
79+
80+
var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken)
81+
.ConfigureAwait(false);
82+
foreach (var sw in switches)
83+
{
84+
await RenderAsync(store, sw, isActive: null, evt.GuildId, evt.ServerId, culture, cancellationToken)
85+
.ConfigureAwait(false);
86+
}
87+
}
88+
}
89+
90+
private async Task RenderAsync(
91+
ISwitchStore store, SmartSwitch sw, bool? isActive, ulong guildId, Guid serverId, string culture,
92+
CancellationToken cancellationToken)
93+
{
94+
var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
95+
if (channelId is not { } channel)
96+
{
97+
return;
98+
}
99+
100+
var (embed, components) = renderer.RenderSwitch(sw, isActive, culture);
101+
var newMessageId = await poster.EnsureAsync(channel, sw.MessageId, embed, components, cancellationToken)
102+
.ConfigureAwait(false);
103+
if (newMessageId is { } mid && mid != sw.MessageId)
104+
{
105+
await store.SetMessageIdAsync(guildId, serverId, sw.EntityId, mid, cancellationToken)
106+
.ConfigureAwait(false);
107+
}
108+
}
109+
110+
private static async Task<string> GetCultureAsync(
111+
IServiceProvider provider, ulong guildId, CancellationToken cancellationToken)
112+
{
113+
var store = provider.GetRequiredService<IWorkspaceStore>();
114+
return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
115+
}
116+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using NSubstitute;
3+
using RustPlusBot.Abstractions.Events;
4+
using RustPlusBot.Domain.Connections;
5+
using RustPlusBot.Domain.Switches;
6+
using RustPlusBot.Features.Switches.Posting;
7+
using RustPlusBot.Features.Switches.Relaying;
8+
using RustPlusBot.Features.Switches.Rendering;
9+
using RustPlusBot.Features.Workspace.Locating;
10+
using RustPlusBot.Persistence.Connections;
11+
using RustPlusBot.Persistence.Switches;
12+
using RustPlusBot.Persistence.Workspace;
13+
14+
namespace RustPlusBot.Features.Switches.Tests;
15+
16+
public sealed class SwitchStateRelayTests
17+
{
18+
private sealed record Harness(SwitchStateRelay Relay, ISwitchStore Store, ISwitchChannelPoster Poster,
19+
IConnectionStore Connections);
20+
21+
private static Harness Create()
22+
{
23+
var store = Substitute.For<ISwitchStore>();
24+
var connections = Substitute.For<IConnectionStore>();
25+
var workspace = Substitute.For<IWorkspaceStore>();
26+
workspace.GetCultureAsync(Arg.Any<ulong>(), Arg.Any<CancellationToken>()).Returns("en");
27+
28+
var services = new ServiceCollection();
29+
services.AddScoped(_ => store);
30+
services.AddScoped(_ => connections);
31+
services.AddScoped(_ => workspace);
32+
var provider = services.BuildServiceProvider();
33+
34+
var locator = Substitute.For<ISwitchChannelLocator>();
35+
locator.GetChannelIdAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>()).Returns(777UL);
36+
var poster = Substitute.For<ISwitchChannelPoster>();
37+
poster.EnsureAsync(Arg.Any<ulong>(), Arg.Any<ulong?>(), Arg.Any<global::Discord.Embed>(),
38+
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>()).Returns((ulong?)900UL);
39+
var renderer = new SwitchEmbedRenderer(new SwitchLocalizer(SwitchLocalizationCatalog.Default));
40+
41+
var relay = new SwitchStateRelay(provider.GetRequiredService<IServiceScopeFactory>(), locator, poster, renderer);
42+
return new Harness(relay, store, poster, connections);
43+
}
44+
45+
[Fact]
46+
public async Task StateChanged_updates_store_and_rerenders()
47+
{
48+
var h = Create();
49+
var serverId = Guid.NewGuid();
50+
h.Store.GetAsync(10UL, serverId, 42UL, Arg.Any<CancellationToken>())
51+
.Returns(new SmartSwitch { GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "G", MessageId = 900UL });
52+
53+
await h.Relay.HandleStateChangedAsync(
54+
new SwitchStateChangedEvent(10UL, serverId, 42UL, IsActive: true), CancellationToken.None);
55+
56+
await h.Store.Received(1).UpdateStateAsync(10UL, serverId, 42UL, true, Arg.Any<CancellationToken>());
57+
await h.Poster.Received(1).EnsureAsync(777UL, 900UL, Arg.Any<global::Discord.Embed>(),
58+
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>());
59+
}
60+
61+
[Fact]
62+
public async Task ConnectionStatus_not_connected_marks_switches_unreachable()
63+
{
64+
var h = Create();
65+
var serverId = Guid.NewGuid();
66+
h.Connections.GetStateAsync(10UL, serverId, Arg.Any<CancellationToken>())
67+
.Returns(new ConnectionState { GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Unreachable });
68+
h.Store.ListByServerAsync(10UL, serverId, Arg.Any<CancellationToken>())
69+
.Returns(new[] { new SmartSwitch { GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "G", MessageId = 900UL } });
70+
71+
await h.Relay.HandleConnectionStatusAsync(
72+
new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None);
73+
74+
await h.Poster.Received(1).EnsureAsync(777UL, 900UL, Arg.Any<global::Discord.Embed>(),
75+
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>());
76+
}
77+
78+
[Fact]
79+
public async Task ConnectionStatus_connected_does_nothing()
80+
{
81+
var h = Create();
82+
var serverId = Guid.NewGuid();
83+
h.Connections.GetStateAsync(10UL, serverId, Arg.Any<CancellationToken>())
84+
.Returns(new ConnectionState { GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Connected });
85+
86+
await h.Relay.HandleConnectionStatusAsync(
87+
new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None);
88+
89+
await h.Poster.DidNotReceive().EnsureAsync(Arg.Any<ulong>(), Arg.Any<ulong?>(), Arg.Any<global::Discord.Embed>(),
90+
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>());
91+
}
92+
}

0 commit comments

Comments
 (0)