Skip to content

Commit d2cb081

Browse files
HandyS11claude
andcommitted
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>
1 parent 41b4cc5 commit d2cb081

10 files changed

Lines changed: 67 additions & 37 deletions

File tree

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
}

src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
namespace RustPlusBot.Features.Alarms.Posting;
77

88
/// <summary>Posts/edits alarm embeds in #alarms by message id. Untested integration shim.</summary>
9-
/// <param name="client">The Discord socket client.</param>
9+
/// <param name="client">The Discord socket client (used directly for the raw @everyone ping).</param>
10+
/// <param name="messenger">The shared gated channel messenger.</param>
1011
/// <param name="logger">The logger.</param>
1112
internal sealed partial class DiscordAlarmChannelPoster(
1213
DiscordSocketClient client,
14+
DiscordChannelMessenger messenger,
1315
ILogger<DiscordAlarmChannelPoster> logger) : IAlarmChannelPoster
1416
{
1517
/// <inheritdoc />
@@ -19,8 +21,7 @@ internal sealed partial class DiscordAlarmChannelPoster(
1921
Embed embed,
2022
MessageComponent components,
2123
CancellationToken cancellationToken)
22-
=> DiscordChannelMessenger.EnsureAsync(client, channelId, messageId, embed, components, logger,
23-
cancellationToken);
24+
=> messenger.EnsureAsync(channelId, messageId, embed, components, logger, cancellationToken);
2425

2526
/// <inheritdoc />
2627
public async Task SendEveryonePingAsync(ulong channelId, string content, CancellationToken cancellationToken)
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
using Discord;
2-
using Discord.WebSocket;
32
using Microsoft.Extensions.Logging;
43
using RustPlusBot.Discord.Posting;
54

65
namespace RustPlusBot.Features.Events.Posting;
76

87
/// <summary>Posts event embeds to Discord text channels via the gateway client.</summary>
9-
/// <param name="client">The Discord socket client.</param>
8+
/// <param name="messenger">The shared gated channel messenger.</param>
109
/// <param name="logger">The logger.</param>
1110
internal sealed class DiscordEventChannelPoster(
12-
DiscordSocketClient client,
11+
DiscordChannelMessenger messenger,
1312
ILogger<DiscordEventChannelPoster> logger)
1413
: IEventChannelPoster
1514
{
1615
/// <inheritdoc />
1716
public Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken)
18-
=> DiscordChannelMessenger.PostAsync(client, channelId, embed, logger, cancellationToken);
17+
=> messenger.PostAsync(channelId, embed, logger, cancellationToken);
1918
}
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
using Discord;
2-
using Discord.WebSocket;
32
using Microsoft.Extensions.Logging;
43
using RustPlusBot.Discord.Posting;
54

65
namespace RustPlusBot.Features.Players.Posting;
76

87
/// <summary>Posts player-event embeds to Discord text channels via the gateway client.</summary>
9-
/// <param name="client">The Discord socket client.</param>
8+
/// <param name="messenger">The shared gated channel messenger.</param>
109
/// <param name="logger">The logger.</param>
1110
internal sealed class DiscordPlayerChannelPoster(
12-
DiscordSocketClient client,
11+
DiscordChannelMessenger messenger,
1312
ILogger<DiscordPlayerChannelPoster> logger)
1413
: IPlayerChannelPoster
1514
{
1615
/// <inheritdoc />
1716
public Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken)
18-
=> DiscordChannelMessenger.PostAsync(client, channelId, embed, logger, cancellationToken);
17+
=> messenger.PostAsync(channelId, embed, logger, cancellationToken);
1918
}
Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
using Discord;
2-
using Discord.WebSocket;
32
using Microsoft.Extensions.Logging;
43
using RustPlusBot.Discord.Posting;
54

65
namespace RustPlusBot.Features.StorageMonitors.Posting;
76

87
/// <summary>Posts/edits storage monitor embeds in #storagemonitors by message id. Untested integration shim.</summary>
9-
/// <param name="client">The Discord socket client.</param>
8+
/// <param name="messenger">The shared gated channel messenger.</param>
109
/// <param name="logger">The logger.</param>
1110
internal sealed class DiscordStorageMonitorChannelPoster(
12-
DiscordSocketClient client,
11+
DiscordChannelMessenger messenger,
1312
ILogger<DiscordStorageMonitorChannelPoster> logger) : IStorageMonitorChannelPoster
1413
{
1514
/// <inheritdoc />
@@ -19,6 +18,5 @@ internal sealed class DiscordStorageMonitorChannelPoster(
1918
Embed embed,
2019
MessageComponent components,
2120
CancellationToken cancellationToken)
22-
=> DiscordChannelMessenger.EnsureAsync(client, channelId, messageId, embed, components, logger,
23-
cancellationToken);
21+
=> messenger.EnsureAsync(channelId, messageId, embed, components, logger, cancellationToken);
2422
}
Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
using Discord;
2-
using Discord.WebSocket;
32
using Microsoft.Extensions.Logging;
43
using RustPlusBot.Discord.Posting;
54

65
namespace RustPlusBot.Features.Switches.Posting;
76

87
/// <summary>Posts/edits switch embeds in #switches by message id. Untested integration shim.</summary>
9-
/// <param name="client">The Discord socket client.</param>
8+
/// <param name="messenger">The shared gated channel messenger.</param>
109
/// <param name="logger">The logger.</param>
1110
internal sealed class DiscordSwitchChannelPoster(
12-
DiscordSocketClient client,
11+
DiscordChannelMessenger messenger,
1312
ILogger<DiscordSwitchChannelPoster> logger) : ISwitchChannelPoster
1413
{
1514
/// <inheritdoc />
@@ -19,6 +18,5 @@ internal sealed class DiscordSwitchChannelPoster(
1918
Embed embed,
2019
MessageComponent components,
2120
CancellationToken cancellationToken)
22-
=> DiscordChannelMessenger.EnsureAsync(client, channelId, messageId, embed, components, logger,
23-
cancellationToken);
21+
=> messenger.EnsureAsync(channelId, messageId, embed, components, logger, cancellationToken);
2422
}

tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using RustPlusBot.Features.Alarms.Posting;
1010
using RustPlusBot.Features.Alarms.Relaying;
1111
using RustPlusBot.Features.Alarms.Rendering;
12+
using RustPlusBot.Discord.Posting;
1213
using RustPlusBot.Features.Connections.Listening;
1314
using RustPlusBot.Features.Workspace.Locating;
1415
using RustPlusBot.Localization;
@@ -55,6 +56,8 @@ public void AddAlarms_resolves_without_captive_dependency_errors()
5556
// Discord
5657
var discordConfig = new DiscordSocketConfig();
5758
services.AddSingleton(new DiscordSocketClient(discordConfig));
59+
services.AddSingleton<RenderGate>();
60+
services.AddSingleton<DiscordChannelMessenger>();
5861

5962
// Scoped stores from Persistence
6063
services.AddScoped(_ => Substitute.For<IAlarmStore>());

tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using NSubstitute;
66
using RustPlusBot.Abstractions.Events;
77
using RustPlusBot.Abstractions.Time;
8+
using RustPlusBot.Discord.Posting;
89
using RustPlusBot.Features.Connections;
910
using RustPlusBot.Features.Connections.Listening;
1011
using RustPlusBot.Features.Events.Hosting;
@@ -39,6 +40,8 @@ private static ServiceProvider BuildProvider()
3940
services.AddSingleton<IClock>(Substitute.For<IClock>());
4041
services.AddSingleton<IEventBus, InMemoryEventBus>();
4142
services.AddSingleton(new DiscordSocketClient());
43+
services.AddSingleton<RenderGate>();
44+
services.AddSingleton<DiscordChannelMessenger>();
4245
services.AddSingleton<IEventChannelLocator>(Substitute.For<IEventChannelLocator>());
4346
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
4447
services.AddScoped<IConnectionStore>(_ => Substitute.For<IConnectionStore>());

tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.Extensions.Hosting;
44
using NSubstitute;
55
using RustPlusBot.Abstractions.Events;
6+
using RustPlusBot.Discord.Posting;
67
using RustPlusBot.Features.Connections.Listening;
78
using RustPlusBot.Features.Players;
89
using RustPlusBot.Features.Players.Hosting;
@@ -28,6 +29,8 @@ private static ServiceProvider BuildProvider()
2829
services.AddLogging();
2930
services.AddSingleton<IEventBus, InMemoryEventBus>();
3031
services.AddSingleton(new DiscordSocketClient());
32+
services.AddSingleton<RenderGate>();
33+
services.AddSingleton<DiscordChannelMessenger>();
3134
services.AddSingleton<IEventChannelLocator>(Substitute.For<IEventChannelLocator>());
3235
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
3336
services.AddSingleton<IBotTeamChatSender>(Substitute.For<IBotTeamChatSender>());

0 commit comments

Comments
 (0)