Skip to content

Commit 12ca406

Browse files
HandyS11claude
andauthored
Quiet boot: skip no-op Discord embed edits + sweep only on real drops (#44)
* feat(discord): add RenderCanonicalizer + RustPlusBot.Discord.Tests project Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(discord): cover canonicalizer fallback; walk non-row components Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(discord): add RenderGate no-op edit detector Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(discord): gate no-op embed edits in DiscordChannelMessenger singleton Skips PATCHes whose render matches the last successful send; adds RetryMode.AlwaysRetry + 30s timeout so bursts degrade to slow catch-up. Also registers RenderGate/DiscordChannelMessenger in the three feature registration tests that build a real DI graph (Alarms, Events, Players) since they wire cross-layer singletons manually instead of via AddDiscordBot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connections): carry IsConnected/WasConnected on ConnectionStatusChangedEvent WasConnected is computed from in-process published history, not the persisted store (which would still say Connected right after boot). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(switches): sweep embeds unreachable only on a drop from Connected Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(alarms): sweep embeds unreachable only on a drop from Connected Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(storage): sweep embeds unreachable only on a drop from Connected Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(discord): register slash commands off the gateway Ready handler Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(discord): offload workspace heal + connection start off the Ready chain Live verification showed these two handlers, not command registration, were what kept the gateway's Ready-blocking warning firing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: fix Ready-offload comment + stale storage relay summary Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(discord): include select-menu option emote in canonical render Copilot review: an option-emoji-only change previously canonicalized identically and the gate would skip the edit. Also documents why _publishedStatuses records before bus delivery (observed-state semantics protect the drop sweep against failed deliveries). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 567fd9c commit 12ca406

32 files changed

Lines changed: 802 additions & 201 deletions

File tree

RustPlusBot.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
<Project Path="tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj" />
3434
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
3535
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
36+
<Project Path="tests/RustPlusBot.Discord.Tests/RustPlusBot.Discord.Tests.csproj" />
3637
<Project Path="tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj" />
3738
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
3839
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />

src/RustPlusBot.Abstractions/Events/ConnectionStatusChangedEvent.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,14 @@ namespace RustPlusBot.Abstractions.Events;
33
/// <summary>Published when a server's live-connection state changes, so #info can re-render.</summary>
44
/// <param name="GuildId">The owning guild snowflake.</param>
55
/// <param name="ServerId">The server whose connection state changed.</param>
6-
public sealed record ConnectionStatusChangedEvent(ulong GuildId, Guid ServerId);
6+
/// <param name="IsConnected">True when the new status is Connected.</param>
7+
/// <param name="WasConnected">
8+
/// True when the previous status published in this process was Connected. Deliberately
9+
/// in-process (not store-derived): the persisted status survives restarts and would still
10+
/// read Connected right after boot, re-triggering unreachable sweeps on every startup.
11+
/// </param>
12+
public sealed record ConnectionStatusChangedEvent(
13+
ulong GuildId,
14+
Guid ServerId,
15+
bool IsConnected,
16+
bool WasConnected);

src/RustPlusBot.Discord/DiscordBotService.cs

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,29 +59,48 @@ public async Task StopAsync(CancellationToken cancellationToken)
5959
await client.StopAsync().ConfigureAwait(false);
6060
}
6161

62-
private async Task OnReadyAsync()
62+
private Task OnReadyAsync()
6363
{
6464
// Ready fires on every gateway (re)connect; only register commands once per process.
65-
// Ready is dispatched serially on the gateway thread, so no synchronization is needed.
65+
// Ready is dispatched serially on the gateway thread, so no synchronization is needed —
66+
// set the flag before offloading so a re-fired Ready can't double-register.
6667
if (_hasRegisteredCommands)
6768
{
68-
return;
69+
return Task.CompletedTask;
6970
}
7071

71-
if (_options.ResetCommandsOnStartup)
72+
_hasRegisteredCommands = true;
73+
74+
// Registration is REST work (one call per guild); doing it inline blocks the gateway task
75+
// and stalls event dispatch, so offload it. Failures must be caught here — nothing awaits this.
76+
_ = Task.Run(RegisterCommandsAsync);
77+
return Task.CompletedTask;
78+
}
79+
80+
private async Task RegisterCommandsAsync()
81+
{
82+
try
7283
{
73-
await client.Rest.DeleteAllGlobalCommandsAsync().ConfigureAwait(false);
74-
logger.LogWarning(
75-
"ResetCommandsOnStartup is enabled: deleted all global application commands before registration.");
76-
}
84+
if (_options.ResetCommandsOnStartup)
85+
{
86+
await client.Rest.DeleteAllGlobalCommandsAsync().ConfigureAwait(false);
87+
logger.LogWarning(
88+
"ResetCommandsOnStartup is enabled: deleted all global application commands before registration.");
89+
}
90+
91+
foreach (var guild in client.Guilds)
92+
{
93+
await interactions.RegisterCommandsToGuildAsync(guild.Id).ConfigureAwait(false);
94+
}
7795

78-
foreach (var guild in client.Guilds)
96+
logger.LogInformation("Registered commands to {GuildCount} guild(s).", client.Guilds.Count);
97+
}
98+
#pragma warning disable CA1031 // Broad catch: fire-and-forget — an unobserved exception would vanish; log it instead.
99+
catch (Exception ex)
100+
#pragma warning restore CA1031
79101
{
80-
await interactions.RegisterCommandsToGuildAsync(guild.Id).ConfigureAwait(false);
102+
logger.LogError(ex, "Registering slash commands failed.");
81103
}
82-
83-
_hasRegisteredCommands = true;
84-
logger.LogInformation("Registered commands to {GuildCount} guild(s).", client.Guilds.Count);
85104
}
86105

87106
[SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance",

src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Discord.WebSocket;
44
using Microsoft.Extensions.DependencyInjection;
55
using RustPlusBot.Discord.Notifications;
6+
using RustPlusBot.Discord.Posting;
67

78
namespace RustPlusBot.Discord;
89

@@ -31,6 +32,8 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services)
3132
DefaultRunMode = RunMode.Async
3233
}));
3334
services.AddSingleton<IUserDmSender, DiscordUserDmSender>();
35+
services.AddSingleton<RenderGate>();
36+
services.AddSingleton<DiscordChannelMessenger>();
3437
services.AddHostedService<DiscordBotService>();
3538

3639
return services;

src/RustPlusBot.Discord/Posting/DiscordChannelMessenger.cs

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,30 @@
55

66
namespace RustPlusBot.Discord.Posting;
77

8-
/// <summary>Shared Discord channel post/edit boilerplate: fetch, options, self-heal, broad-catch.</summary>
9-
public static class DiscordChannelMessenger
8+
/// <summary>
9+
/// Shared Discord channel post/edit boilerplate: fetch, options, self-heal, broad-catch — plus a
10+
/// render gate that skips edits whose content is identical to the last successful send, keeping
11+
/// boot primes and periodic republishes out of Discord's per-channel PATCH rate-limit bucket.
12+
/// </summary>
13+
/// <param name="client">The Discord socket client.</param>
14+
/// <param name="gate">The per-message no-op edit detector.</param>
15+
public sealed class DiscordChannelMessenger(DiscordSocketClient client, RenderGate gate)
1016
{
17+
/// <summary>Request timeout generous enough to ride out a queued rate-limit burst (default is 15 s).</summary>
18+
private const int RequestTimeoutMs = 30_000;
19+
1120
/// <summary>
1221
/// Edits the message by id (self-healing on 404 by reposting) or posts a new one.
22+
/// Identical re-renders are skipped without calling Discord's edit endpoint.
1323
/// Returns the message id, or null on failure.
1424
/// </summary>
15-
/// <param name="client">The Discord socket client.</param>
1625
/// <param name="channelId">The target channel id.</param>
1726
/// <param name="messageId">The existing message id to edit, or null to post a new one.</param>
1827
/// <param name="embed">The embed to post or update.</param>
1928
/// <param name="components">The message components to post or update.</param>
2029
/// <param name="logger">The caller's logger.</param>
2130
/// <param name="cancellationToken">The cancellation token.</param>
22-
public static async Task<ulong?> EnsureAsync(
23-
DiscordSocketClient client,
31+
public async Task<ulong?> EnsureAsync(
2432
ulong channelId,
2533
ulong? messageId,
2634
Embed embed,
@@ -30,16 +38,14 @@ public static class DiscordChannelMessenger
3038
{
3139
try
3240
{
33-
var options = new RequestOptions
34-
{
35-
CancelToken = cancellationToken
36-
};
41+
var options = CreateOptions(cancellationToken);
3742
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
3843
is not ITextChannel channel)
3944
{
4045
return null;
4146
}
4247

48+
var canonical = RenderCanonicalizer.Canonicalize(embed, components);
4349
if (messageId is { } id)
4450
{
4551
// Inner try: some Discord.Net versions THROW (HttpException 404/Unknown Message)
@@ -50,11 +56,18 @@ public static class DiscordChannelMessenger
5056
var existing = await channel.GetMessageAsync(id, options: options).ConfigureAwait(false);
5157
if (existing is IUserMessage userMessage)
5258
{
59+
if (!gate.ShouldSend(id, canonical))
60+
{
61+
// Same content as the last successful send: don't spend the PATCH bucket.
62+
return userMessage.Id;
63+
}
64+
5365
await userMessage.ModifyAsync(m =>
5466
{
5567
m.Embed = embed;
5668
m.Components = components;
5769
}, options).ConfigureAwait(false);
70+
gate.Commit(id, canonical);
5871
return userMessage.Id;
5972
}
6073

@@ -68,11 +81,15 @@ await userMessage.ModifyAsync(m =>
6881
channelId);
6982
#pragma warning restore CA1848, CA1873
7083
}
84+
85+
// The tracked message is gone; the repost below re-keys the gate under the new id.
86+
gate.Invalidate(id);
7187
}
7288

7389
var posted = await channel
7490
.SendMessageAsync(embed: embed, options: options, components: components)
7591
.ConfigureAwait(false);
92+
gate.Commit(posted.Id, canonical);
7693
return posted.Id;
7794
}
7895
catch (OperationCanceledException)
@@ -83,6 +100,12 @@ await userMessage.ModifyAsync(m =>
83100
catch (Exception ex)
84101
#pragma warning restore CA1031
85102
{
103+
if (messageId is { } failedId)
104+
{
105+
// Outcome unknown (e.g. timeout mid-flight): forget the entry so the next render retries.
106+
gate.Invalidate(failedId);
107+
}
108+
86109
#pragma warning disable CA1848, CA1873 // Use LoggerMessage delegates / avoid expensive log-arg evaluation — plain logger.Log is fine for a shared helper (no source-gen partial context); ulong boxing is negligible vs. the caught exception.
87110
logger.LogWarning(ex, "Posting/editing an embed in channel {ChannelId} failed.", channelId);
88111
#pragma warning restore CA1848, CA1873
@@ -91,24 +114,19 @@ await userMessage.ModifyAsync(m =>
91114
}
92115

93116
/// <summary>Posts an embed fire-and-forget; Discord hiccups are logged and swallowed.</summary>
94-
/// <param name="client">The Discord socket client.</param>
95117
/// <param name="channelId">The target channel id.</param>
96118
/// <param name="embed">The embed to post.</param>
97119
/// <param name="logger">The caller's logger.</param>
98120
/// <param name="cancellationToken">The cancellation token.</param>
99-
public static async Task PostAsync(
100-
DiscordSocketClient client,
121+
public async Task PostAsync(
101122
ulong channelId,
102123
Embed embed,
103124
ILogger logger,
104125
CancellationToken cancellationToken)
105126
{
106127
try
107128
{
108-
var options = new RequestOptions
109-
{
110-
CancelToken = cancellationToken
111-
};
129+
var options = CreateOptions(cancellationToken);
112130
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel)
113131
{
114132
return;
@@ -129,4 +147,9 @@ public static async Task PostAsync(
129147
#pragma warning restore CA1848, CA1873
130148
}
131149
}
150+
151+
private static RequestOptions CreateOptions(CancellationToken cancellationToken) => new()
152+
{
153+
CancelToken = cancellationToken, RetryMode = RetryMode.AlwaysRetry, Timeout = RequestTimeoutMs,
154+
};
132155
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using Discord;
4+
5+
namespace RustPlusBot.Discord.Posting;
6+
7+
/// <summary>
8+
/// Flattens an embed + components render into a deterministic canonical string so identical
9+
/// re-renders can be detected and skipped before hitting Discord's per-channel PATCH bucket.
10+
/// Values are length-prefixed to make adjacent user-controlled strings collision-proof.
11+
/// </summary>
12+
/// <remarks>
13+
/// This repo's renderers only ever emit <see cref="ActionRowComponent" />s of buttons and select
14+
/// menus, which are canonicalized field-by-field. Any other component kind (e.g. Components-V2
15+
/// layouts, text inputs) is canonicalized shallowly: only its <see cref="ComponentType" /> and,
16+
/// when it implements <see cref="IInteractableComponent" />, its custom id are captured. Add a
17+
/// dedicated case in <c>AppendComponent</c> if this repo ever sends a richer unmodeled kind.
18+
/// </remarks>
19+
public static class RenderCanonicalizer
20+
{
21+
/// <summary>Builds the canonical string for a render.</summary>
22+
/// <param name="embed">The embed about to be sent, or null.</param>
23+
/// <param name="components">The message components about to be sent, or null.</param>
24+
/// <returns>A deterministic string that is equal iff the visible render is equal.</returns>
25+
public static string Canonicalize(Embed? embed, MessageComponent? components)
26+
{
27+
var sb = new StringBuilder();
28+
if (embed is not null)
29+
{
30+
AppendEmbed(sb, embed);
31+
}
32+
33+
if (components is not null)
34+
{
35+
AppendComponents(sb, components);
36+
}
37+
38+
return sb.ToString();
39+
}
40+
41+
private static void AppendEmbed(StringBuilder sb, Embed embed)
42+
{
43+
Append(sb, "title", embed.Title);
44+
Append(sb, "description", embed.Description);
45+
Append(sb, "url", embed.Url);
46+
Append(sb, "color", embed.Color?.RawValue.ToString(CultureInfo.InvariantCulture));
47+
Append(sb, "timestamp", embed.Timestamp?.UtcDateTime.ToString("O", CultureInfo.InvariantCulture));
48+
Append(sb, "author.name", embed.Author?.Name);
49+
Append(sb, "author.url", embed.Author?.Url);
50+
Append(sb, "author.icon", embed.Author?.IconUrl);
51+
Append(sb, "footer.text", embed.Footer?.Text);
52+
Append(sb, "footer.icon", embed.Footer?.IconUrl);
53+
Append(sb, "thumbnail", embed.Thumbnail?.Url);
54+
Append(sb, "image", embed.Image?.Url);
55+
foreach (var field in embed.Fields)
56+
{
57+
Append(sb, "field.name", field.Name);
58+
Append(sb, "field.value", field.Value);
59+
Append(sb, "field.inline", field.Inline ? "1" : "0");
60+
}
61+
}
62+
63+
private static void AppendComponents(StringBuilder sb, MessageComponent components)
64+
{
65+
foreach (var component in components.Components)
66+
{
67+
if (component is ActionRowComponent row)
68+
{
69+
Append(sb, "row", null);
70+
foreach (var child in row.Components)
71+
{
72+
AppendComponent(sb, child);
73+
}
74+
}
75+
else
76+
{
77+
// Top-level non-ActionRow component (e.g. a Components-V2 layout piece): fall back
78+
// to the same shallow append used for unmodeled component kinds.
79+
AppendComponent(sb, component);
80+
}
81+
}
82+
}
83+
84+
private static void AppendComponent(StringBuilder sb, IMessageComponent component)
85+
{
86+
switch (component)
87+
{
88+
case ButtonComponent button:
89+
Append(sb, "button.id", button.CustomId);
90+
Append(sb, "button.label", button.Label);
91+
Append(sb, "button.style", ((int)button.Style).ToString(CultureInfo.InvariantCulture));
92+
Append(sb, "button.url", button.Url);
93+
Append(sb, "button.disabled", button.IsDisabled ? "1" : "0");
94+
Append(sb, "button.emote", button.Emote?.ToString());
95+
break;
96+
case SelectMenuComponent menu:
97+
Append(sb, "menu.id", menu.CustomId);
98+
Append(sb, "menu.placeholder", menu.Placeholder);
99+
Append(sb, "menu.min", menu.MinValues.ToString(CultureInfo.InvariantCulture));
100+
Append(sb, "menu.max", menu.MaxValues.ToString(CultureInfo.InvariantCulture));
101+
Append(sb, "menu.disabled", menu.IsDisabled ? "1" : "0");
102+
foreach (var option in menu.Options)
103+
{
104+
Append(sb, "option.label", option.Label);
105+
Append(sb, "option.value", option.Value);
106+
Append(sb, "option.description", option.Description);
107+
Append(sb, "option.emote", option.Emote?.ToString());
108+
Append(sb, "option.default", option.IsDefault == true ? "1" : "0");
109+
}
110+
111+
break;
112+
default:
113+
// Unmodeled component kind: type + custom id (when the component exposes one) keeps
114+
// the canonical string honest without building a per-kind serializer for kinds this
115+
// repo never sends (see the class remarks for the accepted depth).
116+
Append(sb, "component.type", component.Type.ToString());
117+
Append(sb, "component.id", (component as IInteractableComponent)?.CustomId);
118+
break;
119+
}
120+
}
121+
122+
private static void Append(StringBuilder sb, string key, string? value)
123+
{
124+
sb.Append(key).Append('=');
125+
if (value is null)
126+
{
127+
sb.Append("<null>");
128+
}
129+
else
130+
{
131+
sb.Append(value.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(value);
132+
}
133+
134+
sb.Append('\n');
135+
}
136+
}

0 commit comments

Comments
 (0)