Skip to content

Commit b82c320

Browse files
HandyS11claude
andcommitted
feat(alarms): add AlarmChannelPoster + AlarmRefresher + AlarmPairingCoordinator
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 032d540 commit b82c320

7 files changed

Lines changed: 641 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
using System.Collections.Concurrent;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using RustPlusBot.Abstractions.Events;
4+
using RustPlusBot.Features.Alarms.Posting;
5+
using RustPlusBot.Features.Alarms.Rendering;
6+
using RustPlusBot.Features.Workspace.Locating;
7+
using RustPlusBot.Persistence.Alarms;
8+
using RustPlusBot.Persistence.Workspace;
9+
10+
namespace RustPlusBot.Features.Alarms.Pairing;
11+
12+
/// <summary>Turns an <see cref="AlarmPairedEvent"/> into an "Add it?" prompt and, on Accept, a managed alarm.</summary>
13+
/// <param name="scopeFactory">Opens scopes for the scoped alarm/workspace stores.</param>
14+
/// <param name="locator">Resolves the #alarms channel id.</param>
15+
/// <param name="poster">Posts/edits alarm + prompt messages.</param>
16+
/// <param name="renderer">Renders the prompt and alarm embeds.</param>
17+
internal sealed class AlarmPairingCoordinator(
18+
IServiceScopeFactory scopeFactory,
19+
IAlarmChannelLocator locator,
20+
IAlarmChannelPoster poster,
21+
AlarmEmbedRenderer 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 alarm 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 alarm: ignore if already managed, else post the prompt and hold pending state.</summary>
34+
/// <param name="evt">The paired-alarm 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 alarm was ignored).</returns>
37+
public async Task HandlePairedAsync(AlarmPairedEvent 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 = $"Alarm {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 alarm 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 alarm 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 alarm was persisted; false when it was already managed (race).</returns>
67+
public async Task<bool> TryAcceptAsync(
68+
ulong guildId,
69+
Guid serverId,
70+
ulong entityId,
71+
ulong acceptingUserId,
72+
CancellationToken cancellationToken)
73+
{
74+
if (await ExistsAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false))
75+
{
76+
_pending.TryRemove((guildId, serverId, entityId), out _);
77+
return false;
78+
}
79+
80+
_pending.TryGetValue((guildId, serverId, entityId), out var pending);
81+
var name = pending?.DefaultName ?? $"Alarm {entityId}";
82+
83+
var scope = scopeFactory.CreateAsyncScope();
84+
await using (scope.ConfigureAwait(false))
85+
{
86+
var store = scope.ServiceProvider.GetRequiredService<IAlarmStore>();
87+
var added = await store.AddAsync(guildId, serverId, entityId, name, acceptingUserId, cancellationToken)
88+
.ConfigureAwait(false);
89+
90+
var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
91+
if (channelId is { } channel)
92+
{
93+
var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
94+
95+
// The alarm is freshly accepted; unreachable is false (it just paired).
96+
// The supervisor's prime path will re-render real state shortly.
97+
var (embed, components) = renderer.RenderAlarm(added, unreachable: false, culture);
98+
var newMessageId = await poster
99+
.EnsureAsync(channel, pending?.MessageId, embed, components, cancellationToken)
100+
.ConfigureAwait(false);
101+
if (newMessageId is { } mid)
102+
{
103+
await store.SetMessageIdAsync(guildId, serverId, entityId, mid, cancellationToken)
104+
.ConfigureAwait(false);
105+
}
106+
}
107+
}
108+
109+
_pending.TryRemove((guildId, serverId, entityId), out _);
110+
return true;
111+
}
112+
113+
/// <summary>Drops a pending pairing; returns whether one was present.</summary>
114+
/// <param name="guildId">The guild id.</param>
115+
/// <param name="serverId">The server id.</param>
116+
/// <param name="entityId">The alarm entity id.</param>
117+
/// <returns>True when a pending pairing was removed; false when none was held.</returns>
118+
public bool TryDismiss(ulong guildId, Guid serverId, ulong entityId) =>
119+
_pending.TryRemove((guildId, serverId, entityId), out _);
120+
121+
private async Task<bool> ExistsAsync(ulong guildId, Guid serverId, ulong entityId, CancellationToken ct)
122+
{
123+
var scope = scopeFactory.CreateAsyncScope();
124+
await using (scope.ConfigureAwait(false))
125+
{
126+
var store = scope.ServiceProvider.GetRequiredService<IAlarmStore>();
127+
return await store.ExistsAsync(guildId, serverId, entityId, ct).ConfigureAwait(false);
128+
}
129+
}
130+
131+
private async Task<string> GetCultureAsync(ulong guildId, CancellationToken ct)
132+
{
133+
var scope = scopeFactory.CreateAsyncScope();
134+
await using (scope.ConfigureAwait(false))
135+
{
136+
var store = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
137+
return await store.GetCultureAsync(guildId, ct).ConfigureAwait(false);
138+
}
139+
}
140+
141+
private sealed record Pending(string DefaultName, ulong? MessageId);
142+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using Discord.Net;
2+
using Discord.WebSocket;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace RustPlusBot.Features.Alarms.Posting;
6+
7+
/// <summary>Posts/edits alarm embeds in #alarms by message id. Untested integration shim.</summary>
8+
/// <param name="client">The Discord socket client.</param>
9+
/// <param name="logger">The logger.</param>
10+
internal sealed partial class DiscordAlarmChannelPoster(
11+
DiscordSocketClient client,
12+
ILogger<DiscordAlarmChannelPoster> logger) : IAlarmChannelPoster
13+
{
14+
/// <inheritdoc />
15+
public async Task<ulong?> EnsureAsync(
16+
ulong channelId,
17+
ulong? messageId,
18+
global::Discord.Embed embed,
19+
global::Discord.MessageComponent components,
20+
CancellationToken cancellationToken)
21+
{
22+
try
23+
{
24+
var options = new global::Discord.RequestOptions
25+
{
26+
CancelToken = cancellationToken
27+
};
28+
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
29+
is not global::Discord.ITextChannel channel)
30+
{
31+
return null;
32+
}
33+
34+
if (messageId is { } id)
35+
{
36+
// Inner try: some Discord.Net versions THROW (HttpException 404/Unknown Message)
37+
// rather than return null for a deleted message. Catch it and fall through to repost
38+
// so the self-heal path always runs.
39+
try
40+
{
41+
var existing = await channel.GetMessageAsync(id, options: options).ConfigureAwait(false);
42+
if (existing is global::Discord.IUserMessage userMessage)
43+
{
44+
await userMessage.ModifyAsync(m =>
45+
{
46+
m.Embed = embed;
47+
m.Components = components;
48+
}, options).ConfigureAwait(false);
49+
return userMessage.Id;
50+
}
51+
52+
// Message was deleted (returned null / not a user message); fall through to repost.
53+
}
54+
catch (HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound)
55+
{
56+
// Deleted/unknown message; fall through to repost and return the new id.
57+
LogMessageMissing(logger, ex, channelId, id);
58+
}
59+
}
60+
61+
var posted = await channel
62+
.SendMessageAsync(embed: embed, options: options, components: components)
63+
.ConfigureAwait(false);
64+
return posted.Id;
65+
}
66+
catch (OperationCanceledException)
67+
{
68+
throw; // Shutdown — let the loop unwind.
69+
}
70+
#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the relay; report failure as null.
71+
catch (Exception ex)
72+
#pragma warning restore CA1031
73+
{
74+
LogEnsureFailed(logger, ex, channelId);
75+
return null;
76+
}
77+
}
78+
79+
/// <inheritdoc />
80+
public async Task SendEveryonePingAsync(ulong channelId, string content, CancellationToken cancellationToken)
81+
{
82+
try
83+
{
84+
var options = new global::Discord.RequestOptions
85+
{
86+
CancelToken = cancellationToken
87+
};
88+
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
89+
is not global::Discord.ITextChannel channel)
90+
{
91+
return;
92+
}
93+
94+
await channel.SendMessageAsync(
95+
content,
96+
options: options,
97+
allowedMentions: global::Discord.AllowedMentions.All)
98+
.ConfigureAwait(false);
99+
}
100+
catch (OperationCanceledException)
101+
{
102+
throw; // Shutdown — let the loop unwind.
103+
}
104+
#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the alarm ping; swallow the failure.
105+
catch (Exception ex)
106+
#pragma warning restore CA1031
107+
{
108+
LogPingFailed(logger, ex, channelId);
109+
}
110+
}
111+
112+
[LoggerMessage(Level = LogLevel.Warning, Message = "Posting/editing an alarm embed in channel {ChannelId} failed.")]
113+
private static partial void LogEnsureFailed(ILogger logger, Exception exception, ulong channelId);
114+
115+
[LoggerMessage(Level = LogLevel.Debug,
116+
Message = "Alarm embed {MessageId} in channel {ChannelId} was deleted; reposting.")]
117+
private static partial void
118+
LogMessageMissing(ILogger logger, Exception exception, ulong channelId, ulong messageId);
119+
120+
[LoggerMessage(Level = LogLevel.Warning,
121+
Message = "Sending @everyone ping in channel {ChannelId} failed.")]
122+
private static partial void LogPingFailed(ILogger logger, Exception exception, ulong channelId);
123+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
namespace RustPlusBot.Features.Alarms.Posting;
2+
3+
/// <summary>Posts/edits an alarm embed in #alarms by message id, self-healing a deleted message.</summary>
4+
internal interface IAlarmChannelPoster
5+
{
6+
/// <summary>Edits the message at <paramref name="messageId"/> if present and found; otherwise posts a new one.</summary>
7+
/// <param name="channelId">The #alarms channel id.</param>
8+
/// <param name="messageId">The known embed message id, or null to post fresh.</param>
9+
/// <param name="embed">The embed to show.</param>
10+
/// <param name="components">The control row.</param>
11+
/// <param name="cancellationToken">A cancellation token.</param>
12+
/// <returns>The (possibly new) message id, or null on failure.</returns>
13+
Task<ulong?> EnsureAsync(
14+
ulong channelId,
15+
ulong? messageId,
16+
global::Discord.Embed embed,
17+
global::Discord.MessageComponent components,
18+
CancellationToken cancellationToken);
19+
20+
/// <summary>Sends an @everyone ping message in the given channel.</summary>
21+
/// <param name="channelId">The #alarms channel id.</param>
22+
/// <param name="content">The message content (typically includes @everyone).</param>
23+
/// <param name="cancellationToken">A cancellation token.</param>
24+
/// <returns>A task that completes when the message has been sent (or silently swallowed on failure).</returns>
25+
Task SendEveryonePingAsync(ulong channelId, string content, CancellationToken cancellationToken);
26+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using RustPlusBot.Features.Alarms.Posting;
3+
using RustPlusBot.Features.Alarms.Rendering;
4+
using RustPlusBot.Features.Workspace.Locating;
5+
using RustPlusBot.Persistence.Alarms;
6+
using RustPlusBot.Persistence.Workspace;
7+
8+
namespace RustPlusBot.Features.Alarms.Relaying;
9+
10+
/// <summary>Loads an alarm from the store, renders it, and posts/edits its embed in #alarms.</summary>
11+
/// <param name="scopeFactory">Opens scopes for the scoped stores.</param>
12+
/// <param name="locator">Resolves the #alarms channel id.</param>
13+
/// <param name="poster">Posts/edits alarm embeds.</param>
14+
/// <param name="renderer">Renders alarm embeds.</param>
15+
internal sealed class AlarmRefresher(
16+
IServiceScopeFactory scopeFactory,
17+
IAlarmChannelLocator locator,
18+
IAlarmChannelPoster poster,
19+
AlarmEmbedRenderer renderer) : IAlarmRefresher
20+
{
21+
/// <inheritdoc />
22+
public async Task RefreshAsync(ulong guildId, Guid serverId, ulong entityId, bool unreachable, CancellationToken ct)
23+
{
24+
var scope = scopeFactory.CreateAsyncScope();
25+
await using (scope.ConfigureAwait(false))
26+
{
27+
var store = scope.ServiceProvider.GetRequiredService<IAlarmStore>();
28+
var alarm = await store.GetAsync(guildId, serverId, entityId, ct).ConfigureAwait(false);
29+
if (alarm is null)
30+
{
31+
return;
32+
}
33+
34+
var channelId = await locator.GetChannelIdAsync(guildId, serverId, ct).ConfigureAwait(false);
35+
if (channelId is not { } channel)
36+
{
37+
return;
38+
}
39+
40+
var culture = await GetCultureAsync(scope.ServiceProvider, guildId, ct).ConfigureAwait(false);
41+
var (embed, components) = renderer.RenderAlarm(alarm, unreachable, culture);
42+
var newMessageId = await poster
43+
.EnsureAsync(channel, alarm.MessageId, embed, components, ct)
44+
.ConfigureAwait(false);
45+
if (newMessageId is { } mid && mid != alarm.MessageId)
46+
{
47+
await store.SetMessageIdAsync(guildId, serverId, entityId, mid, ct).ConfigureAwait(false);
48+
}
49+
}
50+
}
51+
52+
private static async Task<string> GetCultureAsync(
53+
IServiceProvider provider,
54+
ulong guildId,
55+
CancellationToken ct)
56+
{
57+
var store = provider.GetRequiredService<IWorkspaceStore>();
58+
return await store.GetCultureAsync(guildId, ct).ConfigureAwait(false);
59+
}
60+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace RustPlusBot.Features.Alarms.Relaying;
2+
3+
/// <summary>Re-renders a single alarm's embed on demand (prime, reconnect, or trigger).</summary>
4+
internal interface IAlarmRefresher
5+
{
6+
/// <summary>Loads the alarm, renders it, and posts or edits its embed.</summary>
7+
/// <param name="guildId">The owning Discord guild snowflake.</param>
8+
/// <param name="serverId">The Rust server id.</param>
9+
/// <param name="entityId">The in-game smart-alarm entity id.</param>
10+
/// <param name="unreachable">When true the alarm entity is currently unreachable.</param>
11+
/// <param name="ct">A cancellation token.</param>
12+
/// <returns>A task that completes when the embed has been refreshed (or no-op if alarm/channel absent).</returns>
13+
Task RefreshAsync(ulong guildId, Guid serverId, ulong entityId, bool unreachable, CancellationToken ct);
14+
}

0 commit comments

Comments
 (0)