Skip to content

Commit aa5d9ae

Browse files
HandyS11claude
andcommitted
feat(alarms): add AlarmComponentModule + rename modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ab69627 commit aa5d9ae

3 files changed

Lines changed: 234 additions & 1 deletion

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
using System.Globalization;
2+
using Discord;
3+
using Discord.Interactions;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using RustPlusBot.Features.Alarms.Pairing;
6+
using RustPlusBot.Features.Alarms.Relaying;
7+
using RustPlusBot.Features.Alarms.Rendering;
8+
using RustPlusBot.Persistence.Alarms;
9+
10+
namespace RustPlusBot.Features.Alarms.Modules;
11+
12+
/// <summary>Thin handler for the #alarms pairing prompt + control buttons + rename modal. Any guild member.</summary>
13+
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
14+
/// <param name="refresher">Re-renders the alarm embed on demand.</param>
15+
public sealed class AlarmComponentModule(
16+
IServiceScopeFactory scopeFactory,
17+
IAlarmRefresher refresher) : InteractionModuleBase<SocketInteractionContext>
18+
{
19+
/// <summary>Accepts a pending pairing prompt and starts managing the alarm.</summary>
20+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
21+
[ComponentInteraction(AlarmComponentIds.AcceptPrefix + "*")]
22+
public async Task AcceptAsync(string tail)
23+
{
24+
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
25+
{
26+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
27+
return;
28+
}
29+
30+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
31+
var scope = scopeFactory.CreateAsyncScope();
32+
await using (scope.ConfigureAwait(false))
33+
{
34+
var coordinator = scope.ServiceProvider.GetRequiredService<AlarmPairingCoordinator>();
35+
var accepted = await coordinator
36+
.TryAcceptAsync(Context.Guild.Id, serverId, entityId, Context.User.Id, CancellationToken.None)
37+
.ConfigureAwait(false);
38+
await FollowupAsync(accepted ? "Alarm added." : "That alarm is already managed.", ephemeral: true)
39+
.ConfigureAwait(false);
40+
}
41+
}
42+
43+
/// <summary>Dismisses a pending pairing prompt and removes the transient prompt message.</summary>
44+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
45+
[ComponentInteraction(AlarmComponentIds.DismissPrefix + "*")]
46+
public async Task DismissAsync(string tail)
47+
{
48+
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
49+
{
50+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
51+
return;
52+
}
53+
54+
var scope = scopeFactory.CreateAsyncScope();
55+
await using (scope.ConfigureAwait(false))
56+
{
57+
var coordinator = scope.ServiceProvider.GetRequiredService<AlarmPairingCoordinator>();
58+
coordinator.TryDismiss(Context.Guild.Id, serverId, entityId);
59+
}
60+
61+
// Delete the actual prompt message that hosts this button (the component interaction's source
62+
// message) — not the ephemeral interaction response. Best-effort: a delete failure is non-fatal.
63+
await DeletePromptMessageSafeAsync().ConfigureAwait(false);
64+
await RespondAsync("Dismissed.", ephemeral: true).ConfigureAwait(false);
65+
}
66+
67+
/// <summary>Toggles the @everyone ping setting for this alarm.</summary>
68+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
69+
[ComponentInteraction(AlarmComponentIds.PingTogglePrefix + "*")]
70+
public async Task PingToggleAsync(string tail)
71+
{
72+
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
73+
{
74+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
75+
return;
76+
}
77+
78+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
79+
var scope = scopeFactory.CreateAsyncScope();
80+
await using (scope.ConfigureAwait(false))
81+
{
82+
var store = scope.ServiceProvider.GetRequiredService<IAlarmStore>();
83+
var current = await store.GetAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None)
84+
.ConfigureAwait(false);
85+
if (current is null)
86+
{
87+
await FollowupAsync("That alarm isn't managed.", ephemeral: true).ConfigureAwait(false);
88+
return;
89+
}
90+
91+
await store
92+
.SetPingEveryoneAsync(Context.Guild.Id, serverId, entityId, !current.PingEveryone, CancellationToken.None)
93+
.ConfigureAwait(false);
94+
}
95+
96+
await refresher.RefreshAsync(Context.Guild.Id, serverId, entityId, unreachable: false, CancellationToken.None)
97+
.ConfigureAwait(false);
98+
await FollowupAsync("Updated.", ephemeral: true).ConfigureAwait(false);
99+
}
100+
101+
/// <summary>Toggles the relay-to-team-chat setting for this alarm.</summary>
102+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
103+
[ComponentInteraction(AlarmComponentIds.RelayTogglePrefix + "*")]
104+
public async Task RelayToggleAsync(string tail)
105+
{
106+
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
107+
{
108+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
109+
return;
110+
}
111+
112+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
113+
var scope = scopeFactory.CreateAsyncScope();
114+
await using (scope.ConfigureAwait(false))
115+
{
116+
var store = scope.ServiceProvider.GetRequiredService<IAlarmStore>();
117+
var current = await store.GetAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None)
118+
.ConfigureAwait(false);
119+
if (current is null)
120+
{
121+
await FollowupAsync("That alarm isn't managed.", ephemeral: true).ConfigureAwait(false);
122+
return;
123+
}
124+
125+
await store
126+
.SetRelayToTeamChatAsync(Context.Guild.Id, serverId, entityId, !current.RelayToTeamChat,
127+
CancellationToken.None)
128+
.ConfigureAwait(false);
129+
}
130+
131+
await refresher.RefreshAsync(Context.Guild.Id, serverId, entityId, unreachable: false, CancellationToken.None)
132+
.ConfigureAwait(false);
133+
await FollowupAsync("Updated.", ephemeral: true).ConfigureAwait(false);
134+
}
135+
136+
/// <summary>Opens the rename modal, carrying the target tail in the modal custom id.</summary>
137+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
138+
[ComponentInteraction(AlarmComponentIds.RenamePrefix + "*")]
139+
public async Task RenamePromptAsync(string tail)
140+
{
141+
if (!TryParse(tail, out _, out _) || Context.Guild is null)
142+
{
143+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
144+
return;
145+
}
146+
147+
// The modal id carries the same tail so the submit handler can route.
148+
await RespondWithModalAsync<AlarmRenameModal>(AlarmComponentIds.RenameModalPrefix + tail)
149+
.ConfigureAwait(false);
150+
}
151+
152+
/// <summary>Persists the new name, then refreshes the alarm embed.</summary>
153+
/// <param name="tail">The "{serverId}:{entityId}" custom-id tail.</param>
154+
/// <param name="modal">The submitted rename modal.</param>
155+
[ModalInteraction(AlarmComponentIds.RenameModalPrefix + "*")]
156+
public async Task RenameSubmitAsync(string tail, AlarmRenameModal modal)
157+
{
158+
ArgumentNullException.ThrowIfNull(modal);
159+
if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
160+
{
161+
await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
162+
return;
163+
}
164+
165+
var name = string.IsNullOrWhiteSpace(modal.Name)
166+
? "Alarm " + entityId.ToString(CultureInfo.InvariantCulture)
167+
: modal.Name.Trim();
168+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
169+
170+
var scope = scopeFactory.CreateAsyncScope();
171+
await using (scope.ConfigureAwait(false))
172+
{
173+
var store = scope.ServiceProvider.GetRequiredService<IAlarmStore>();
174+
await store.RenameAsync(Context.Guild.Id, serverId, entityId, name, CancellationToken.None)
175+
.ConfigureAwait(false);
176+
}
177+
178+
await refresher.RefreshAsync(Context.Guild.Id, serverId, entityId, unreachable: false, CancellationToken.None)
179+
.ConfigureAwait(false);
180+
await FollowupAsync("Renamed.", ephemeral: true).ConfigureAwait(false);
181+
}
182+
183+
private static bool TryParse(string tail, out Guid serverId, out ulong entityId)
184+
{
185+
serverId = Guid.Empty;
186+
entityId = 0UL;
187+
if (tail is null)
188+
{
189+
return false;
190+
}
191+
192+
var parts = tail.Split(':');
193+
return parts.Length == 2
194+
&& Guid.TryParse(parts[0], out serverId)
195+
&& ulong.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out entityId);
196+
}
197+
198+
private async Task DeletePromptMessageSafeAsync()
199+
{
200+
try
201+
{
202+
// The source message of a component interaction is the prompt that carries the button.
203+
if (Context.Interaction is IComponentInteraction component)
204+
{
205+
await component.Message.DeleteAsync().ConfigureAwait(false);
206+
}
207+
}
208+
#pragma warning disable CA1031 // Best-effort prompt cleanup; a delete failure is non-fatal.
209+
catch (Exception ex)
210+
#pragma warning restore CA1031
211+
{
212+
// Ignore: the prompt is transient and harmless if it lingers.
213+
_ = ex;
214+
}
215+
}
216+
}
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.Alarms.Rendering;
4+
5+
namespace RustPlusBot.Features.Alarms.Modules;
6+
7+
/// <summary>The modal that collects a new alarm name. Handled by <see cref="AlarmComponentModule"/>.</summary>
8+
public sealed class AlarmRenameModal : IModal
9+
{
10+
/// <summary>The new name.</summary>
11+
[InputLabel("Alarm name")]
12+
[ModalTextInput(AlarmComponentIds.RenameInputId, TextInputStyle.Short, maxLength: 128)]
13+
public string Name { get; set; } = string.Empty;
14+
15+
/// <inheritdoc />
16+
public string Title => "Rename alarm";
17+
}

src/RustPlusBot.Features.Alarms/Relaying/IAlarmRefresher.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
namespace RustPlusBot.Features.Alarms.Relaying;
22

33
/// <summary>Re-renders a single alarm's embed on demand (prime, reconnect, or trigger).</summary>
4-
internal interface IAlarmRefresher
4+
public interface IAlarmRefresher
55
{
66
/// <summary>Loads the alarm, renders it, and posts or edits its embed.</summary>
77
/// <param name="guildId">The owning Discord guild snowflake.</param>

0 commit comments

Comments
 (0)