Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<Project Path="tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
<Project Path="tests/RustPlusBot.Discord.Tests/RustPlusBot.Discord.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,14 @@ namespace RustPlusBot.Abstractions.Events;
/// <summary>Published when a server's live-connection state changes, so #info can re-render.</summary>
/// <param name="GuildId">The owning guild snowflake.</param>
/// <param name="ServerId">The server whose connection state changed.</param>
public sealed record ConnectionStatusChangedEvent(ulong GuildId, Guid ServerId);
/// <param name="IsConnected">True when the new status is Connected.</param>
/// <param name="WasConnected">
/// True when the previous status published in this process was Connected. Deliberately
/// in-process (not store-derived): the persisted status survives restarts and would still
/// read Connected right after boot, re-triggering unreachable sweeps on every startup.
/// </param>
public sealed record ConnectionStatusChangedEvent(
ulong GuildId,
Guid ServerId,
bool IsConnected,
bool WasConnected);
45 changes: 32 additions & 13 deletions src/RustPlusBot.Discord/DiscordBotService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,29 +59,48 @@ public async Task StopAsync(CancellationToken cancellationToken)
await client.StopAsync().ConfigureAwait(false);
}

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

if (_options.ResetCommandsOnStartup)
_hasRegisteredCommands = true;

// Registration is REST work (one call per guild); doing it inline blocks the gateway task
// and stalls event dispatch, so offload it. Failures must be caught here — nothing awaits this.
_ = Task.Run(RegisterCommandsAsync);
return Task.CompletedTask;
}

private async Task RegisterCommandsAsync()
{
try
{
await client.Rest.DeleteAllGlobalCommandsAsync().ConfigureAwait(false);
logger.LogWarning(
"ResetCommandsOnStartup is enabled: deleted all global application commands before registration.");
}
if (_options.ResetCommandsOnStartup)
{
await client.Rest.DeleteAllGlobalCommandsAsync().ConfigureAwait(false);
logger.LogWarning(
"ResetCommandsOnStartup is enabled: deleted all global application commands before registration.");
}

foreach (var guild in client.Guilds)
{
await interactions.RegisterCommandsToGuildAsync(guild.Id).ConfigureAwait(false);
}

foreach (var guild in client.Guilds)
logger.LogInformation("Registered commands to {GuildCount} guild(s).", client.Guilds.Count);
}
#pragma warning disable CA1031 // Broad catch: fire-and-forget — an unobserved exception would vanish; log it instead.
catch (Exception ex)
#pragma warning restore CA1031
{
await interactions.RegisterCommandsToGuildAsync(guild.Id).ConfigureAwait(false);
logger.LogError(ex, "Registering slash commands failed.");
}

_hasRegisteredCommands = true;
logger.LogInformation("Registered commands to {GuildCount} guild(s).", client.Guilds.Count);
}

[SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance",
Expand Down
3 changes: 3 additions & 0 deletions src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Discord.Notifications;
using RustPlusBot.Discord.Posting;

namespace RustPlusBot.Discord;

Expand Down Expand Up @@ -31,6 +32,8 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services)
DefaultRunMode = RunMode.Async
}));
services.AddSingleton<IUserDmSender, DiscordUserDmSender>();
services.AddSingleton<RenderGate>();
services.AddSingleton<DiscordChannelMessenger>();
services.AddHostedService<DiscordBotService>();

return services;
Expand Down
55 changes: 39 additions & 16 deletions src/RustPlusBot.Discord/Posting/DiscordChannelMessenger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,30 @@

namespace RustPlusBot.Discord.Posting;

/// <summary>Shared Discord channel post/edit boilerplate: fetch, options, self-heal, broad-catch.</summary>
public static class DiscordChannelMessenger
/// <summary>
/// Shared Discord channel post/edit boilerplate: fetch, options, self-heal, broad-catch — plus a
/// render gate that skips edits whose content is identical to the last successful send, keeping
/// boot primes and periodic republishes out of Discord's per-channel PATCH rate-limit bucket.
/// </summary>
/// <param name="client">The Discord socket client.</param>
/// <param name="gate">The per-message no-op edit detector.</param>
public sealed class DiscordChannelMessenger(DiscordSocketClient client, RenderGate gate)
{
/// <summary>Request timeout generous enough to ride out a queued rate-limit burst (default is 15 s).</summary>
private const int RequestTimeoutMs = 30_000;

/// <summary>
/// Edits the message by id (self-healing on 404 by reposting) or posts a new one.
/// Identical re-renders are skipped without calling Discord's edit endpoint.
/// Returns the message id, or null on failure.
/// </summary>
/// <param name="client">The Discord socket client.</param>
/// <param name="channelId">The target channel id.</param>
/// <param name="messageId">The existing message id to edit, or null to post a new one.</param>
/// <param name="embed">The embed to post or update.</param>
/// <param name="components">The message components to post or update.</param>
/// <param name="logger">The caller's logger.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public static async Task<ulong?> EnsureAsync(
DiscordSocketClient client,
public async Task<ulong?> EnsureAsync(
ulong channelId,
ulong? messageId,
Embed embed,
Expand All @@ -30,16 +38,14 @@ public static class DiscordChannelMessenger
{
try
{
var options = new RequestOptions
{
CancelToken = cancellationToken
};
var options = CreateOptions(cancellationToken);
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false)
is not ITextChannel channel)
{
return null;
}

var canonical = RenderCanonicalizer.Canonicalize(embed, components);
if (messageId is { } id)
{
// Inner try: some Discord.Net versions THROW (HttpException 404/Unknown Message)
Expand All @@ -50,11 +56,18 @@ public static class DiscordChannelMessenger
var existing = await channel.GetMessageAsync(id, options: options).ConfigureAwait(false);
if (existing is IUserMessage userMessage)
{
if (!gate.ShouldSend(id, canonical))
{
// Same content as the last successful send: don't spend the PATCH bucket.
return userMessage.Id;
}

await userMessage.ModifyAsync(m =>
{
m.Embed = embed;
m.Components = components;
}, options).ConfigureAwait(false);
gate.Commit(id, canonical);
return userMessage.Id;
}

Expand All @@ -68,11 +81,15 @@ await userMessage.ModifyAsync(m =>
channelId);
#pragma warning restore CA1848, CA1873
}

// The tracked message is gone; the repost below re-keys the gate under the new id.
gate.Invalidate(id);
}

var posted = await channel
.SendMessageAsync(embed: embed, options: options, components: components)
.ConfigureAwait(false);
gate.Commit(posted.Id, canonical);
return posted.Id;
}
catch (OperationCanceledException)
Expand All @@ -83,6 +100,12 @@ await userMessage.ModifyAsync(m =>
catch (Exception ex)
#pragma warning restore CA1031
{
if (messageId is { } failedId)
{
// Outcome unknown (e.g. timeout mid-flight): forget the entry so the next render retries.
gate.Invalidate(failedId);
}

#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.
logger.LogWarning(ex, "Posting/editing an embed in channel {ChannelId} failed.", channelId);
#pragma warning restore CA1848, CA1873
Expand All @@ -91,24 +114,19 @@ await userMessage.ModifyAsync(m =>
}

/// <summary>Posts an embed fire-and-forget; Discord hiccups are logged and swallowed.</summary>
/// <param name="client">The Discord socket client.</param>
/// <param name="channelId">The target channel id.</param>
/// <param name="embed">The embed to post.</param>
/// <param name="logger">The caller's logger.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public static async Task PostAsync(
DiscordSocketClient client,
public async Task PostAsync(
ulong channelId,
Embed embed,
ILogger logger,
CancellationToken cancellationToken)
{
try
{
var options = new RequestOptions
{
CancelToken = cancellationToken
};
var options = CreateOptions(cancellationToken);
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel)
{
return;
Expand All @@ -129,4 +147,9 @@ public static async Task PostAsync(
#pragma warning restore CA1848, CA1873
}
}

private static RequestOptions CreateOptions(CancellationToken cancellationToken) => new()
{
CancelToken = cancellationToken, RetryMode = RetryMode.AlwaysRetry, Timeout = RequestTimeoutMs,
};
}
136 changes: 136 additions & 0 deletions src/RustPlusBot.Discord/Posting/RenderCanonicalizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Globalization;
using System.Text;
using Discord;

namespace RustPlusBot.Discord.Posting;

/// <summary>
/// Flattens an embed + components render into a deterministic canonical string so identical
/// re-renders can be detected and skipped before hitting Discord's per-channel PATCH bucket.
/// Values are length-prefixed to make adjacent user-controlled strings collision-proof.
/// </summary>
/// <remarks>
/// This repo's renderers only ever emit <see cref="ActionRowComponent" />s of buttons and select
/// menus, which are canonicalized field-by-field. Any other component kind (e.g. Components-V2
/// layouts, text inputs) is canonicalized shallowly: only its <see cref="ComponentType" /> and,
/// when it implements <see cref="IInteractableComponent" />, its custom id are captured. Add a
/// dedicated case in <c>AppendComponent</c> if this repo ever sends a richer unmodeled kind.
/// </remarks>
public static class RenderCanonicalizer
{
/// <summary>Builds the canonical string for a render.</summary>
/// <param name="embed">The embed about to be sent, or null.</param>
/// <param name="components">The message components about to be sent, or null.</param>
/// <returns>A deterministic string that is equal iff the visible render is equal.</returns>
public static string Canonicalize(Embed? embed, MessageComponent? components)
{
var sb = new StringBuilder();
if (embed is not null)
{
AppendEmbed(sb, embed);
}

if (components is not null)
{
AppendComponents(sb, components);
}

return sb.ToString();
}

private static void AppendEmbed(StringBuilder sb, Embed embed)
{
Append(sb, "title", embed.Title);
Append(sb, "description", embed.Description);
Append(sb, "url", embed.Url);
Append(sb, "color", embed.Color?.RawValue.ToString(CultureInfo.InvariantCulture));
Append(sb, "timestamp", embed.Timestamp?.UtcDateTime.ToString("O", CultureInfo.InvariantCulture));
Append(sb, "author.name", embed.Author?.Name);
Append(sb, "author.url", embed.Author?.Url);
Append(sb, "author.icon", embed.Author?.IconUrl);
Append(sb, "footer.text", embed.Footer?.Text);
Append(sb, "footer.icon", embed.Footer?.IconUrl);
Append(sb, "thumbnail", embed.Thumbnail?.Url);
Append(sb, "image", embed.Image?.Url);
foreach (var field in embed.Fields)
{
Append(sb, "field.name", field.Name);
Append(sb, "field.value", field.Value);
Append(sb, "field.inline", field.Inline ? "1" : "0");
}
}

private static void AppendComponents(StringBuilder sb, MessageComponent components)
{
foreach (var component in components.Components)
{
if (component is ActionRowComponent row)
{
Append(sb, "row", null);
foreach (var child in row.Components)
{
AppendComponent(sb, child);
}
}
else
{
// Top-level non-ActionRow component (e.g. a Components-V2 layout piece): fall back
// to the same shallow append used for unmodeled component kinds.
AppendComponent(sb, component);
}
}
}

private static void AppendComponent(StringBuilder sb, IMessageComponent component)
{
switch (component)
{
case ButtonComponent button:
Append(sb, "button.id", button.CustomId);
Append(sb, "button.label", button.Label);
Append(sb, "button.style", ((int)button.Style).ToString(CultureInfo.InvariantCulture));
Append(sb, "button.url", button.Url);
Append(sb, "button.disabled", button.IsDisabled ? "1" : "0");
Append(sb, "button.emote", button.Emote?.ToString());
break;
case SelectMenuComponent menu:
Append(sb, "menu.id", menu.CustomId);
Append(sb, "menu.placeholder", menu.Placeholder);
Append(sb, "menu.min", menu.MinValues.ToString(CultureInfo.InvariantCulture));
Append(sb, "menu.max", menu.MaxValues.ToString(CultureInfo.InvariantCulture));
Append(sb, "menu.disabled", menu.IsDisabled ? "1" : "0");
foreach (var option in menu.Options)
{
Append(sb, "option.label", option.Label);
Append(sb, "option.value", option.Value);
Append(sb, "option.description", option.Description);
Append(sb, "option.emote", option.Emote?.ToString());
Append(sb, "option.default", option.IsDefault == true ? "1" : "0");
}
Comment on lines +102 to +109

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8a97780option.emote is now part of the canonical walk (select menus are a fully modeled kind here, and buttons already included theirs), with a regression test (Select_menu_option_emote_change_changes_the_canonical_string).


break;
default:
// Unmodeled component kind: type + custom id (when the component exposes one) keeps
// the canonical string honest without building a per-kind serializer for kinds this
// repo never sends (see the class remarks for the accepted depth).
Append(sb, "component.type", component.Type.ToString());
Append(sb, "component.id", (component as IInteractableComponent)?.CustomId);
break;
}
}

private static void Append(StringBuilder sb, string key, string? value)
{
sb.Append(key).Append('=');
if (value is null)
{
sb.Append("<null>");
}
else
{
sb.Append(value.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(value);
}

sb.Append('\n');
}
}
Loading
Loading