Skip to content

Commit 1e74b0a

Browse files
committed
feat(switches): add SwitchComponentModule + rename modal (thin interaction surface)
1 parent 4867fe7 commit 1e74b0a

2 files changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
using System.Globalization;
2+
using Discord;
3+
using Discord.Interactions;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using RustPlusBot.Abstractions.Events;
6+
using RustPlusBot.Features.Connections.Listening;
7+
using RustPlusBot.Features.Switches.Pairing;
8+
using RustPlusBot.Features.Switches.Rendering;
9+
using RustPlusBot.Persistence.Switches;
10+
11+
namespace RustPlusBot.Features.Switches.Modules;
12+
13+
/// <summary>Thin handler for the #switches pairing prompt + control buttons + rename modal. Any guild member.</summary>
14+
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
15+
/// <param name="query">Live socket read/control.</param>
16+
/// <param name="eventBus">Publishes a state-changed event to drive an embed refresh.</param>
17+
public sealed class SwitchComponentModule(
18+
IServiceScopeFactory scopeFactory,
19+
IRustServerQuery query,
20+
IEventBus eventBus) : InteractionModuleBase<SocketInteractionContext>
21+
{
22+
/// <summary>Accepts a pending pairing prompt and starts managing the switch.</summary>
23+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
24+
[ComponentInteraction(SwitchComponentIds.AcceptPrefix + "*")]
25+
public async Task AcceptAsync(string tail)
26+
{
27+
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
28+
{
29+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
30+
return;
31+
}
32+
33+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
34+
var scope = scopeFactory.CreateAsyncScope();
35+
await using (scope.ConfigureAwait(false))
36+
{
37+
var coordinator = scope.ServiceProvider.GetRequiredService<SwitchPairingCoordinator>();
38+
var accepted = await coordinator
39+
.TryAcceptAsync(Context.Guild.Id, serverId, entityId, Context.User.Id, CancellationToken.None)
40+
.ConfigureAwait(false);
41+
await FollowupAsync(accepted ? "Switch added." : "That switch is already managed.", ephemeral: true)
42+
.ConfigureAwait(false);
43+
}
44+
}
45+
46+
/// <summary>Dismisses a pending pairing prompt and removes the transient prompt message.</summary>
47+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
48+
[ComponentInteraction(SwitchComponentIds.DismissPrefix + "*")]
49+
public async Task DismissAsync(string tail)
50+
{
51+
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
52+
{
53+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
54+
return;
55+
}
56+
57+
var scope = scopeFactory.CreateAsyncScope();
58+
await using (scope.ConfigureAwait(false))
59+
{
60+
var coordinator = scope.ServiceProvider.GetRequiredService<SwitchPairingCoordinator>();
61+
coordinator.TryDismiss(Context.Guild.Id, serverId, entityId);
62+
}
63+
64+
// Best-effort: remove the transient prompt message.
65+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
66+
await DeleteOriginalResponseSafeAsync().ConfigureAwait(false);
67+
}
68+
69+
/// <summary>Turns the switch on.</summary>
70+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
71+
[ComponentInteraction(SwitchComponentIds.OnPrefix + "*")]
72+
public Task OnAsync(string tail) => SetAsync(tail, value: true);
73+
74+
/// <summary>Turns the switch off.</summary>
75+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
76+
[ComponentInteraction(SwitchComponentIds.OffPrefix + "*")]
77+
public Task OffAsync(string tail) => SetAsync(tail, value: false);
78+
79+
/// <summary>Strobes the switch on, then re-reads and publishes the settled state.</summary>
80+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
81+
[ComponentInteraction(SwitchComponentIds.StrobePrefix + "*")]
82+
public async Task StrobeAsync(string tail)
83+
{
84+
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
85+
{
86+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
87+
return;
88+
}
89+
90+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
91+
var ok = await query
92+
.StrobeSmartSwitchAsync(Context.Guild.Id, serverId, entityId, timeoutMs: 1000, value: true,
93+
CancellationToken.None)
94+
.ConfigureAwait(false);
95+
if (!ok)
96+
{
97+
await FollowupAsync("Switch is unreachable right now.", ephemeral: true).ConfigureAwait(false);
98+
return;
99+
}
100+
101+
var state = await query.GetSmartSwitchStateAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None)
102+
.ConfigureAwait(false);
103+
await eventBus
104+
.PublishAsync(new SwitchStateChangedEvent(Context.Guild.Id, serverId, entityId, state ?? true))
105+
.ConfigureAwait(false);
106+
await FollowupAsync("Strobed.", ephemeral: true).ConfigureAwait(false);
107+
}
108+
109+
/// <summary>Opens the rename modal, carrying the target tail in the modal custom id.</summary>
110+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
111+
[ComponentInteraction(SwitchComponentIds.RenamePrefix + "*")]
112+
public async Task RenamePromptAsync(string tail)
113+
{
114+
if (!TryParse(tail, out _, out _) || Context.Guild is null)
115+
{
116+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
117+
return;
118+
}
119+
120+
// The modal id carries the same tail so the submit handler can route.
121+
await RespondWithModalAsync<SwitchRenameModal>(SwitchComponentIds.RenameModalPrefix + tail)
122+
.ConfigureAwait(false);
123+
}
124+
125+
/// <summary>Persists the new name, then publishes the current state so the embed refreshes.</summary>
126+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
127+
/// <param name="modal">The submitted rename modal.</param>
128+
[ModalInteraction(SwitchComponentIds.RenameModalPrefix + "*")]
129+
public async Task RenameSubmitAsync(string tail, SwitchRenameModal modal)
130+
{
131+
ArgumentNullException.ThrowIfNull(modal);
132+
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
133+
{
134+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
135+
return;
136+
}
137+
138+
var name = string.IsNullOrWhiteSpace(modal.Name) ? "Switch " + entityId.ToString(CultureInfo.InvariantCulture)
139+
: modal.Name.Trim();
140+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
141+
142+
bool isActive;
143+
var scope = scopeFactory.CreateAsyncScope();
144+
await using (scope.ConfigureAwait(false))
145+
{
146+
var store = scope.ServiceProvider.GetRequiredService<ISwitchStore>();
147+
await store.RenameAsync(Context.Guild.Id, serverId, entityId, name, CancellationToken.None)
148+
.ConfigureAwait(false);
149+
var sw = await store.GetAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None)
150+
.ConfigureAwait(false);
151+
isActive = sw?.LastIsActive ?? false;
152+
}
153+
154+
await eventBus
155+
.PublishAsync(new SwitchStateChangedEvent(Context.Guild.Id, serverId, entityId, isActive))
156+
.ConfigureAwait(false);
157+
await FollowupAsync("Renamed.", ephemeral: true).ConfigureAwait(false);
158+
}
159+
160+
private async Task SetAsync(string tail, bool value)
161+
{
162+
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
163+
{
164+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
165+
return;
166+
}
167+
168+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
169+
var ok = await query.SetSmartSwitchAsync(Context.Guild.Id, serverId, entityId, value, CancellationToken.None)
170+
.ConfigureAwait(false);
171+
if (!ok)
172+
{
173+
await FollowupAsync("Switch is unreachable right now.", ephemeral: true).ConfigureAwait(false);
174+
return;
175+
}
176+
177+
await eventBus.PublishAsync(new SwitchStateChangedEvent(Context.Guild.Id, serverId, entityId, value))
178+
.ConfigureAwait(false);
179+
await FollowupAsync(value ? "Turned on." : "Turned off.", ephemeral: true).ConfigureAwait(false);
180+
}
181+
182+
private static bool TryParse(string tail, out Guid serverId, out ulong entityId)
183+
{
184+
serverId = Guid.Empty;
185+
entityId = 0UL;
186+
if (tail is null)
187+
{
188+
return false;
189+
}
190+
191+
var parts = tail.Split(':');
192+
return parts.Length == 2
193+
&& Guid.TryParse(parts[0], out serverId)
194+
&& ulong.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out entityId);
195+
}
196+
197+
private async Task DeleteOriginalResponseSafeAsync()
198+
{
199+
try
200+
{
201+
await DeleteOriginalResponseAsync().ConfigureAwait(false);
202+
}
203+
#pragma warning disable CA1031 // Best-effort prompt cleanup; a delete failure is non-fatal.
204+
catch (Exception ex)
205+
#pragma warning restore CA1031
206+
{
207+
// Ignore: the prompt is transient and harmless if it lingers.
208+
_ = ex;
209+
}
210+
}
211+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Discord;
2+
using Discord.Interactions;
3+
using RustPlusBot.Features.Switches.Rendering;
4+
5+
namespace RustPlusBot.Features.Switches.Modules;
6+
7+
/// <summary>The modal that collects a new switch name. Handled by <see cref="SwitchComponentModule"/>.</summary>
8+
public sealed class SwitchRenameModal : IModal
9+
{
10+
/// <summary>The new name.</summary>
11+
[InputLabel("Switch name")]
12+
[ModalTextInput(SwitchComponentIds.RenameInputId, TextInputStyle.Short, maxLength: 128)]
13+
public string Name { get; set; } = string.Empty;
14+
15+
/// <inheritdoc />
16+
public string Title => "Rename switch";
17+
}

0 commit comments

Comments
 (0)