Skip to content

Commit 482877b

Browse files
committed
feat(switches): add SwitchPairingCoordinator (prompt, accept, dedupe)
1 parent deb0c7f commit 482877b

2 files changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using System.Collections.Concurrent;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using RustPlusBot.Abstractions.Events;
4+
using RustPlusBot.Features.Switches.Posting;
5+
using RustPlusBot.Features.Switches.Rendering;
6+
using RustPlusBot.Features.Workspace.Locating;
7+
using RustPlusBot.Persistence.Switches;
8+
using RustPlusBot.Persistence.Workspace;
9+
10+
namespace RustPlusBot.Features.Switches.Pairing;
11+
12+
/// <summary>Turns a <see cref="SwitchPairedEvent"/> into an "Add it?" prompt and, on Accept, a managed switch.</summary>
13+
/// <param name="scopeFactory">Opens scopes for the scoped switch/workspace stores.</param>
14+
/// <param name="locator">Resolves the #switches channel id.</param>
15+
/// <param name="poster">Posts/edits switch + prompt messages.</param>
16+
/// <param name="renderer">Renders the prompt and switch embeds.</param>
17+
internal sealed class SwitchPairingCoordinator(
18+
IServiceScopeFactory scopeFactory,
19+
ISwitchChannelLocator locator,
20+
ISwitchChannelPoster poster,
21+
SwitchEmbedRenderer renderer)
22+
{
23+
private readonly ConcurrentDictionary<(ulong Guild, Guid Server, ulong Entity), Pending> _pending = new();
24+
25+
/// <summary>Gets the held default name for a pending pairing, or null.</summary>
26+
/// <param name="guildId">The guild id.</param>
27+
/// <param name="serverId">The server id.</param>
28+
/// <param name="entityId">The switch entity id.</param>
29+
/// <returns>The held default name, or null when no pending pairing exists.</returns>
30+
public string? PendingName(ulong guildId, Guid serverId, ulong entityId) =>
31+
_pending.TryGetValue((guildId, serverId, entityId), out var p) ? p.DefaultName : null;
32+
33+
/// <summary>Handles a paired switch: ignore if already managed, else post the prompt and hold pending state.</summary>
34+
/// <param name="evt">The paired-switch event.</param>
35+
/// <param name="cancellationToken">A token to cancel the operation.</param>
36+
/// <returns>A task that completes when the prompt has been posted (or the switch was ignored).</returns>
37+
public async Task HandlePairedAsync(SwitchPairedEvent evt, CancellationToken cancellationToken)
38+
{
39+
ArgumentNullException.ThrowIfNull(evt);
40+
if (await ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken).ConfigureAwait(false))
41+
{
42+
return;
43+
}
44+
45+
var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
46+
.ConfigureAwait(false);
47+
if (channelId is not { } channel)
48+
{
49+
return;
50+
}
51+
52+
var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false);
53+
var defaultName = $"Switch {evt.EntityId}";
54+
var (embed, components) = renderer.RenderPrompt(evt.ServerId, evt.EntityId, defaultName, culture);
55+
var messageId = await poster.EnsureAsync(channel, null, embed, components, cancellationToken)
56+
.ConfigureAwait(false);
57+
_pending[(evt.GuildId, evt.ServerId, evt.EntityId)] = new Pending(defaultName, messageId);
58+
}
59+
60+
/// <summary>Accepts a pending pairing: persist + replace prompt with the switch embed. Race-guarded.</summary>
61+
/// <param name="guildId">The guild id.</param>
62+
/// <param name="serverId">The server id.</param>
63+
/// <param name="entityId">The switch entity id.</param>
64+
/// <param name="acceptingUserId">The id of the user who accepted the pairing.</param>
65+
/// <param name="cancellationToken">A token to cancel the operation.</param>
66+
/// <returns>True when the switch was persisted; false when it was already managed (race).</returns>
67+
public async Task<bool> TryAcceptAsync(
68+
ulong guildId, Guid serverId, ulong entityId, ulong acceptingUserId, CancellationToken cancellationToken)
69+
{
70+
if (await ExistsAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false))
71+
{
72+
_pending.TryRemove((guildId, serverId, entityId), out _);
73+
return false;
74+
}
75+
76+
_pending.TryGetValue((guildId, serverId, entityId), out var pending);
77+
var name = pending?.DefaultName ?? $"Switch {entityId}";
78+
79+
var scope = scopeFactory.CreateAsyncScope();
80+
await using (scope.ConfigureAwait(false))
81+
{
82+
var store = scope.ServiceProvider.GetRequiredService<ISwitchStore>();
83+
var added = await store.AddAsync(guildId, serverId, entityId, name, acceptingUserId, cancellationToken)
84+
.ConfigureAwait(false);
85+
86+
var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
87+
if (channelId is { } channel)
88+
{
89+
var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
90+
91+
// The switch is freshly accepted; state is unknown until the next prime/trigger, so render the
92+
// persisted LastIsActive (defaults false). The supervisor's prime path republishes real state shortly.
93+
var (embed, components) = renderer.RenderSwitch(added, isActive: added.LastIsActive, culture);
94+
var newMessageId = await poster
95+
.EnsureAsync(channel, pending?.MessageId, embed, components, cancellationToken)
96+
.ConfigureAwait(false);
97+
if (newMessageId is { } mid)
98+
{
99+
await store.SetMessageIdAsync(guildId, serverId, entityId, mid, cancellationToken)
100+
.ConfigureAwait(false);
101+
}
102+
}
103+
}
104+
105+
_pending.TryRemove((guildId, serverId, entityId), out _);
106+
return true;
107+
}
108+
109+
/// <summary>Drops a pending pairing; returns whether one was present.</summary>
110+
/// <param name="guildId">The guild id.</param>
111+
/// <param name="serverId">The server id.</param>
112+
/// <param name="entityId">The switch entity id.</param>
113+
/// <returns>True when a pending pairing was removed; false when none was held.</returns>
114+
public bool TryDismiss(ulong guildId, Guid serverId, ulong entityId) =>
115+
_pending.TryRemove((guildId, serverId, entityId), out _);
116+
117+
private async Task<bool> ExistsAsync(ulong guildId, Guid serverId, ulong entityId, CancellationToken ct)
118+
{
119+
var scope = scopeFactory.CreateAsyncScope();
120+
await using (scope.ConfigureAwait(false))
121+
{
122+
var store = scope.ServiceProvider.GetRequiredService<ISwitchStore>();
123+
return await store.ExistsAsync(guildId, serverId, entityId, ct).ConfigureAwait(false);
124+
}
125+
}
126+
127+
private async Task<string> GetCultureAsync(ulong guildId, CancellationToken ct)
128+
{
129+
var scope = scopeFactory.CreateAsyncScope();
130+
await using (scope.ConfigureAwait(false))
131+
{
132+
var store = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
133+
return await store.GetCultureAsync(guildId, ct).ConfigureAwait(false);
134+
}
135+
}
136+
137+
private sealed record Pending(string DefaultName, ulong? MessageId);
138+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using NSubstitute;
3+
using RustPlusBot.Abstractions.Events;
4+
using RustPlusBot.Features.Switches.Pairing;
5+
using RustPlusBot.Features.Switches.Posting;
6+
using RustPlusBot.Features.Switches.Rendering;
7+
using RustPlusBot.Features.Workspace.Locating;
8+
using RustPlusBot.Persistence.Switches;
9+
using RustPlusBot.Persistence.Workspace;
10+
11+
namespace RustPlusBot.Features.Switches.Tests;
12+
13+
public sealed class SwitchPairingCoordinatorTests
14+
{
15+
private sealed record Harness(
16+
SwitchPairingCoordinator Coordinator,
17+
ISwitchStore Store,
18+
ISwitchChannelPoster Poster,
19+
ISwitchChannelLocator Locator);
20+
21+
private static Harness Create()
22+
{
23+
var store = Substitute.For<ISwitchStore>();
24+
var workspace = Substitute.For<IWorkspaceStore>();
25+
workspace.GetCultureAsync(Arg.Any<ulong>(), Arg.Any<CancellationToken>()).Returns("en");
26+
27+
var services = new ServiceCollection();
28+
services.AddScoped(_ => store);
29+
services.AddScoped(_ => workspace);
30+
var provider = services.BuildServiceProvider();
31+
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
32+
33+
var locator = Substitute.For<ISwitchChannelLocator>();
34+
locator.GetChannelIdAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
35+
.Returns(777UL);
36+
37+
var poster = Substitute.For<ISwitchChannelPoster>();
38+
poster.EnsureAsync(Arg.Any<ulong>(), Arg.Any<ulong?>(), Arg.Any<global::Discord.Embed>(),
39+
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>())
40+
.Returns(900UL);
41+
42+
var renderer = new SwitchEmbedRenderer(new SwitchLocalizer(SwitchLocalizationCatalog.Default));
43+
var coordinator = new SwitchPairingCoordinator(scopeFactory, locator, poster, renderer);
44+
return new Harness(coordinator, store, poster, locator);
45+
}
46+
47+
[Fact]
48+
public async Task Paired_new_switch_posts_prompt_with_default_name()
49+
{
50+
var h = Create();
51+
var serverId = Guid.NewGuid();
52+
h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any<CancellationToken>()).Returns(false);
53+
54+
await h.Coordinator.HandlePairedAsync(new SwitchPairedEvent(10UL, serverId, 42UL), CancellationToken.None);
55+
56+
await h.Poster.Received(1).EnsureAsync(777UL, null, Arg.Any<global::Discord.Embed>(),
57+
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>());
58+
Assert.Equal("Switch 42", h.Coordinator.PendingName(10UL, serverId, 42UL));
59+
}
60+
61+
[Fact]
62+
public async Task Paired_already_managed_switch_is_ignored()
63+
{
64+
var h = Create();
65+
var serverId = Guid.NewGuid();
66+
h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any<CancellationToken>()).Returns(true);
67+
68+
await h.Coordinator.HandlePairedAsync(new SwitchPairedEvent(10UL, serverId, 42UL), CancellationToken.None);
69+
70+
await h.Poster.DidNotReceive().EnsureAsync(Arg.Any<ulong>(), Arg.Any<ulong?>(), Arg.Any<global::Discord.Embed>(),
71+
Arg.Any<global::Discord.MessageComponent>(), Arg.Any<CancellationToken>());
72+
Assert.Null(h.Coordinator.PendingName(10UL, serverId, 42UL));
73+
}
74+
75+
[Fact]
76+
public async Task Accept_persists_switch_and_replaces_prompt()
77+
{
78+
var h = Create();
79+
var serverId = Guid.NewGuid();
80+
h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any<CancellationToken>()).Returns(false);
81+
await h.Coordinator.HandlePairedAsync(new SwitchPairedEvent(10UL, serverId, 42UL), CancellationToken.None);
82+
h.Store.AddAsync(10UL, serverId, 42UL, "Switch 42", 5UL, Arg.Any<CancellationToken>())
83+
.Returns(new RustPlusBot.Domain.Switches.SmartSwitch
84+
{
85+
GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "Switch 42",
86+
});
87+
88+
var ok = await h.Coordinator.TryAcceptAsync(10UL, serverId, 42UL, acceptingUserId: 5UL, CancellationToken.None);
89+
90+
Assert.True(ok);
91+
await h.Store.Received(1).AddAsync(10UL, serverId, 42UL, "Switch 42", 5UL, Arg.Any<CancellationToken>());
92+
Assert.Null(h.Coordinator.PendingName(10UL, serverId, 42UL)); // pending cleared
93+
}
94+
95+
[Fact]
96+
public async Task Accept_is_noop_when_already_persisted_by_race()
97+
{
98+
var h = Create();
99+
var serverId = Guid.NewGuid();
100+
h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any<CancellationToken>()).Returns(true);
101+
102+
var ok = await h.Coordinator.TryAcceptAsync(10UL, serverId, 42UL, 5UL, CancellationToken.None);
103+
104+
Assert.False(ok);
105+
await h.Store.DidNotReceive().AddAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<ulong>(),
106+
Arg.Any<string>(), Arg.Any<ulong>(), Arg.Any<CancellationToken>());
107+
}
108+
}

0 commit comments

Comments
 (0)