diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx
index 53eb82f3..40f51b35 100644
--- a/RustPlusBot.slnx
+++ b/RustPlusBot.slnx
@@ -33,6 +33,7 @@
+
diff --git a/src/RustPlusBot.Abstractions/Events/ConnectionStatusChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/ConnectionStatusChangedEvent.cs
index 9c23a391..4a2dd086 100644
--- a/src/RustPlusBot.Abstractions/Events/ConnectionStatusChangedEvent.cs
+++ b/src/RustPlusBot.Abstractions/Events/ConnectionStatusChangedEvent.cs
@@ -3,4 +3,14 @@ namespace RustPlusBot.Abstractions.Events;
/// Published when a server's live-connection state changes, so #info can re-render.
/// The owning guild snowflake.
/// The server whose connection state changed.
-public sealed record ConnectionStatusChangedEvent(ulong GuildId, Guid ServerId);
+/// True when the new status is Connected.
+///
+/// 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.
+///
+public sealed record ConnectionStatusChangedEvent(
+ ulong GuildId,
+ Guid ServerId,
+ bool IsConnected,
+ bool WasConnected);
diff --git a/src/RustPlusBot.Discord/DiscordBotService.cs b/src/RustPlusBot.Discord/DiscordBotService.cs
index b85560a8..75c4bfca 100644
--- a/src/RustPlusBot.Discord/DiscordBotService.cs
+++ b/src/RustPlusBot.Discord/DiscordBotService.cs
@@ -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",
diff --git a/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs b/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs
index 0168b492..1cbc19c8 100644
--- a/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs
@@ -3,6 +3,7 @@
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Discord.Notifications;
+using RustPlusBot.Discord.Posting;
namespace RustPlusBot.Discord;
@@ -31,6 +32,8 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services)
DefaultRunMode = RunMode.Async
}));
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddHostedService();
return services;
diff --git a/src/RustPlusBot.Discord/Posting/DiscordChannelMessenger.cs b/src/RustPlusBot.Discord/Posting/DiscordChannelMessenger.cs
index 0f0dca16..91dc684c 100644
--- a/src/RustPlusBot.Discord/Posting/DiscordChannelMessenger.cs
+++ b/src/RustPlusBot.Discord/Posting/DiscordChannelMessenger.cs
@@ -5,22 +5,30 @@
namespace RustPlusBot.Discord.Posting;
-/// Shared Discord channel post/edit boilerplate: fetch, options, self-heal, broad-catch.
-public static class DiscordChannelMessenger
+///
+/// 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.
+///
+/// The Discord socket client.
+/// The per-message no-op edit detector.
+public sealed class DiscordChannelMessenger(DiscordSocketClient client, RenderGate gate)
{
+ /// Request timeout generous enough to ride out a queued rate-limit burst (default is 15 s).
+ private const int RequestTimeoutMs = 30_000;
+
///
/// 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.
///
- /// The Discord socket client.
/// The target channel id.
/// The existing message id to edit, or null to post a new one.
/// The embed to post or update.
/// The message components to post or update.
/// The caller's logger.
/// The cancellation token.
- public static async Task EnsureAsync(
- DiscordSocketClient client,
+ public async Task EnsureAsync(
ulong channelId,
ulong? messageId,
Embed embed,
@@ -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)
@@ -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;
}
@@ -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)
@@ -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
@@ -91,13 +114,11 @@ await userMessage.ModifyAsync(m =>
}
/// Posts an embed fire-and-forget; Discord hiccups are logged and swallowed.
- /// The Discord socket client.
/// The target channel id.
/// The embed to post.
/// The caller's logger.
/// The cancellation token.
- public static async Task PostAsync(
- DiscordSocketClient client,
+ public async Task PostAsync(
ulong channelId,
Embed embed,
ILogger logger,
@@ -105,10 +126,7 @@ public static async Task PostAsync(
{
try
{
- var options = new RequestOptions
- {
- CancelToken = cancellationToken
- };
+ var options = CreateOptions(cancellationToken);
if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel)
{
return;
@@ -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,
+ };
}
diff --git a/src/RustPlusBot.Discord/Posting/RenderCanonicalizer.cs b/src/RustPlusBot.Discord/Posting/RenderCanonicalizer.cs
new file mode 100644
index 00000000..59c6e67d
--- /dev/null
+++ b/src/RustPlusBot.Discord/Posting/RenderCanonicalizer.cs
@@ -0,0 +1,136 @@
+using System.Globalization;
+using System.Text;
+using Discord;
+
+namespace RustPlusBot.Discord.Posting;
+
+///
+/// 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.
+///
+///
+/// This repo's renderers only ever emit 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 and,
+/// when it implements , its custom id are captured. Add a
+/// dedicated case in AppendComponent if this repo ever sends a richer unmodeled kind.
+///
+public static class RenderCanonicalizer
+{
+ /// Builds the canonical string for a render.
+ /// The embed about to be sent, or null.
+ /// The message components about to be sent, or null.
+ /// A deterministic string that is equal iff the visible render is equal.
+ 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");
+ }
+
+ 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("");
+ }
+ else
+ {
+ sb.Append(value.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(value);
+ }
+
+ sb.Append('\n');
+ }
+}
diff --git a/src/RustPlusBot.Discord/Posting/RenderGate.cs b/src/RustPlusBot.Discord/Posting/RenderGate.cs
new file mode 100644
index 00000000..0c4f0ca0
--- /dev/null
+++ b/src/RustPlusBot.Discord/Posting/RenderGate.cs
@@ -0,0 +1,30 @@
+using System.Collections.Concurrent;
+
+namespace RustPlusBot.Discord.Posting;
+
+///
+/// Per-process memory of the last render successfully sent per Discord message, so identical
+/// re-renders (boot primes, periodic contents republishes) skip the PATCH entirely. Entries are
+/// committed only after a confirmed send; a failed or deleted message is invalidated so the next
+/// render always retries. Restart cost: one edit per message to re-warm the cache — by design.
+///
+public sealed class RenderGate
+{
+ private readonly ConcurrentDictionary _lastSent = new();
+
+ /// Decides whether a render differs from the last committed one for the message.
+ /// The Discord message id the render targets.
+ /// The canonical render string (see ).
+ /// True when the render must be sent (differs or nothing committed yet).
+ public bool ShouldSend(ulong messageId, string canonicalRender)
+ => !(_lastSent.TryGetValue(messageId, out var last) && last == canonicalRender);
+
+ /// Records a successfully sent render for the message.
+ /// The Discord message id that was posted or edited.
+ /// The canonical render string that was sent.
+ public void Commit(ulong messageId, string canonicalRender) => _lastSent[messageId] = canonicalRender;
+
+ /// Forgets the message (send failed or message deleted); the next render always sends.
+ /// The Discord message id to forget.
+ public void Invalidate(ulong messageId) => _lastSent.TryRemove(messageId, out _);
+}
diff --git a/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs b/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs
index b68cc000..603fddf2 100644
--- a/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs
+++ b/src/RustPlusBot.Features.Alarms/Posting/DiscordAlarmChannelPoster.cs
@@ -6,10 +6,12 @@
namespace RustPlusBot.Features.Alarms.Posting;
/// Posts/edits alarm embeds in #alarms by message id. Untested integration shim.
-/// The Discord socket client.
+/// The Discord socket client (used directly for the raw @everyone ping).
+/// The shared gated channel messenger.
/// The logger.
internal sealed partial class DiscordAlarmChannelPoster(
DiscordSocketClient client,
+ DiscordChannelMessenger messenger,
ILogger logger) : IAlarmChannelPoster
{
///
@@ -19,8 +21,7 @@ internal sealed partial class DiscordAlarmChannelPoster(
Embed embed,
MessageComponent components,
CancellationToken cancellationToken)
- => DiscordChannelMessenger.EnsureAsync(client, channelId, messageId, embed, components, logger,
- cancellationToken);
+ => messenger.EnsureAsync(channelId, messageId, embed, components, logger, cancellationToken);
///
public async Task SendEveryonePingAsync(ulong channelId, string content, CancellationToken cancellationToken)
diff --git a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs
index 5056005c..cae08a99 100644
--- a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs
+++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs
@@ -2,15 +2,12 @@
using Microsoft.Extensions.Logging;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
-using RustPlusBot.Domain.Connections;
using RustPlusBot.Features.Alarms.Posting;
using RustPlusBot.Features.Alarms.Rendering;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Localization;
using RustPlusBot.Persistence.Alarms;
-using RustPlusBot.Persistence.Connections;
-using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Persistence.Workspace;
namespace RustPlusBot.Features.Alarms.Relaying;
@@ -26,7 +23,7 @@ internal sealed record AlarmRelayChannels(
///
/// Keeps alarm embeds in sync with live socket events: updates state and re-renders on trigger; marks
-/// alarms unreachable when the server goes non-Connected.
+/// alarms unreachable only on a drop from Connected.
///
/// Opens scopes for the scoped stores.
/// Re-renders a single alarm embed on demand.
@@ -104,27 +101,24 @@ await refresher.RefreshAsync(evt.GuildId, evt.ServerId, evt.EntityId, unreachabl
}
}
- ///
- /// Handles a connection-status change: if the server is no longer Connected, marks every managed
- /// alarm's embed as unreachable. Connected → no-op (the supervisor's prime republishes real state).
- ///
+ /// Handles a connection-status change: a drop from Connected marks its alarm embeds unreachable.
/// The connection-status change.
/// A cancellation token.
/// A task that completes when every affected embed has been re-rendered.
public async Task HandleConnectionStatusAsync(ConnectionStatusChangedEvent evt, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(evt);
+ if (evt.IsConnected || !evt.WasConnected)
+ {
+ // Connected: the supervisor's prime path republishes real state — nothing to do.
+ // Never-connected in this process (boot, reconnect-loop repeats): keep the last-run
+ // embeds; only a drop from Connected sweeps them to unreachable.
+ return;
+ }
+
var scope = scopeFactory.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
- var connections = scope.ServiceProvider.GetRequiredService();
- var state = await connections.GetStateAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false);
- if (state is { Status: ConnectionStatus.Connected })
- {
- // The supervisor's prime path republishes real state on connect; nothing to do here.
- return;
- }
-
var store = scope.ServiceProvider.GetRequiredService();
var alarms = await store.ListByServerAsync(evt.GuildId, evt.ServerId, ct).ConfigureAwait(false);
foreach (var alarm in alarms)
diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
index 8eb065c3..901053d1 100644
--- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
+++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
@@ -46,6 +46,15 @@ internal sealed partial class ConnectionSupervisor(
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), LiveSocket> _liveSockets = new();
private readonly ConnectionOptions _options = options.Value;
+
+ ///
+ /// Last status this process REACHED THE PUBLISH STEP WITH per key — the store's persisted status
+ /// survives restarts and would falsely report Connected at boot. Recorded before bus delivery on
+ /// purpose: WasConnected must reflect what the supervisor observed, so a failed/cancelled delivery
+ /// of a Connected event cannot make the next real drop skip its unreachable sweep.
+ ///
+ private readonly ConcurrentDictionary<(ulong Guild, Guid Server), ConnectionStatus> _publishedStatuses = new();
+
private readonly CancellationTokenSource _shutdown = new();
private bool _disposed;
@@ -953,7 +962,12 @@ private async Task PublishStatusAsync(
if (changed)
{
- await eventBus.PublishAsync(new ConnectionStatusChangedEvent(key.Guild, key.Server), ct)
+ var wasConnected = _publishedStatuses.TryGetValue(key, out var previous)
+ && previous == ConnectionStatus.Connected;
+ _publishedStatuses[key] = status;
+ await eventBus.PublishAsync(
+ new ConnectionStatusChangedEvent(key.Guild, key.Server,
+ status == ConnectionStatus.Connected, wasConnected), ct)
.ConfigureAwait(false);
}
}
diff --git a/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs b/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs
index 46861218..3009a98d 100644
--- a/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs
+++ b/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs
@@ -1,19 +1,18 @@
using Discord;
-using Discord.WebSocket;
using Microsoft.Extensions.Logging;
using RustPlusBot.Discord.Posting;
namespace RustPlusBot.Features.Events.Posting;
/// Posts event embeds to Discord text channels via the gateway client.
-/// The Discord socket client.
+/// The shared gated channel messenger.
/// The logger.
internal sealed class DiscordEventChannelPoster(
- DiscordSocketClient client,
+ DiscordChannelMessenger messenger,
ILogger logger)
: IEventChannelPoster
{
///
public Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken)
- => DiscordChannelMessenger.PostAsync(client, channelId, embed, logger, cancellationToken);
+ => messenger.PostAsync(channelId, embed, logger, cancellationToken);
}
diff --git a/src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs b/src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs
index b030a5e7..5a114da6 100644
--- a/src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs
+++ b/src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs
@@ -30,17 +30,28 @@ public async Task StopAsync(CancellationToken cancellationToken)
await supervisor.StopAllAsync().ConfigureAwait(false);
}
- private async Task OnReadyAsync()
+ private Task OnReadyAsync()
{
// Ready fires on every (re)connect; only start listeners once per process. Ready is dispatched
// serially on the gateway thread, so the plain bool guard needs no synchronization (same pattern
- // as DiscordBotService and WorkspaceHostedService).
+ // as DiscordBotService and WorkspaceHostedService) — set the flag before offloading so a
+ // re-fired Ready can't double-start.
if (_started)
{
- return;
+ return Task.CompletedTask;
}
_started = true;
+
+ // Starting FCM pairing listeners involves network I/O; doing it inline blocks the gateway
+ // task and stalls event dispatch, so offload it. Failures must be caught here — nothing awaits
+ // this.
+ _ = Task.Run(StartListenersAsync);
+ return Task.CompletedTask;
+ }
+
+ private async Task StartListenersAsync()
+ {
try
{
await supervisor.StartAllActiveAsync().ConfigureAwait(false);
diff --git a/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs b/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs
index a27619ca..c62f1489 100644
--- a/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs
+++ b/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs
@@ -1,19 +1,18 @@
using Discord;
-using Discord.WebSocket;
using Microsoft.Extensions.Logging;
using RustPlusBot.Discord.Posting;
namespace RustPlusBot.Features.Players.Posting;
/// Posts player-event embeds to Discord text channels via the gateway client.
-/// The Discord socket client.
+/// The shared gated channel messenger.
/// The logger.
internal sealed class DiscordPlayerChannelPoster(
- DiscordSocketClient client,
+ DiscordChannelMessenger messenger,
ILogger logger)
: IPlayerChannelPoster
{
///
public Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken)
- => DiscordChannelMessenger.PostAsync(client, channelId, embed, logger, cancellationToken);
+ => messenger.PostAsync(channelId, embed, logger, cancellationToken);
}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs b/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs
index 1406c6b2..3c809ce0 100644
--- a/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs
+++ b/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs
@@ -1,15 +1,14 @@
using Discord;
-using Discord.WebSocket;
using Microsoft.Extensions.Logging;
using RustPlusBot.Discord.Posting;
namespace RustPlusBot.Features.StorageMonitors.Posting;
/// Posts/edits storage monitor embeds in #storagemonitors by message id. Untested integration shim.
-/// The Discord socket client.
+/// The shared gated channel messenger.
/// The logger.
internal sealed class DiscordStorageMonitorChannelPoster(
- DiscordSocketClient client,
+ DiscordChannelMessenger messenger,
ILogger logger) : IStorageMonitorChannelPoster
{
///
@@ -19,6 +18,5 @@ internal sealed class DiscordStorageMonitorChannelPoster(
Embed embed,
MessageComponent components,
CancellationToken cancellationToken)
- => DiscordChannelMessenger.EnsureAsync(client, channelId, messageId, embed, components, logger,
- cancellationToken);
+ => messenger.EnsureAsync(channelId, messageId, embed, components, logger, cancellationToken);
}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs b/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs
index fafbea55..ffd1cb4e 100644
--- a/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs
+++ b/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs
@@ -1,18 +1,16 @@
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.StorageMonitors;
using RustPlusBot.Features.StorageMonitors.Posting;
using RustPlusBot.Features.StorageMonitors.Rendering;
using RustPlusBot.Features.Workspace.Locating;
-using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.StorageMonitors;
using RustPlusBot.Persistence.Workspace;
namespace RustPlusBot.Features.StorageMonitors.Relaying;
-/// Keeps storage monitor embeds in sync: trigger events re-render with the carried contents; a non-Connected server marks them unreachable.
+/// Keeps storage monitor embeds in sync: trigger events re-render with the carried contents; a drop from Connected marks them unreachable.
/// Opens scopes for the scoped stores.
/// Resolves the #storagemonitors channel id.
/// Posts/edits storage monitor embeds.
@@ -98,7 +96,7 @@ await RenderAsync(store, monitor, contents, evt.GuildId, evt.ServerId, culture,
}
}
- /// Handles a connection-status change: a non-Connected server marks its storage monitor embeds unreachable.
+ /// Handles a connection-status change: a drop from Connected marks its storage embeds unreachable.
/// The connection-status change.
/// A cancellation token.
/// A task that completes when every affected embed has been re-rendered.
@@ -107,18 +105,17 @@ public async Task HandleConnectionStatusAsync(
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(evt);
+ if (evt.IsConnected || !evt.WasConnected)
+ {
+ // Connected: the supervisor's prime path republishes real state — nothing to do.
+ // Never-connected in this process (boot, reconnect-loop repeats): keep the last-run
+ // embeds; only a drop from Connected sweeps them to unreachable.
+ return;
+ }
+
var scope = scopeFactory.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
- var connections = scope.ServiceProvider.GetRequiredService();
- var state = await connections.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken)
- .ConfigureAwait(false);
- if (state is { Status: ConnectionStatus.Connected })
- {
- // The supervisor's prime path republishes real state on connect; nothing to do here.
- return;
- }
-
var store = scope.ServiceProvider.GetRequiredService();
var monitors = await store.ListByServerAsync(evt.GuildId, evt.ServerId, cancellationToken)
.ConfigureAwait(false);
diff --git a/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs b/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs
index a689f2df..7a67d38d 100644
--- a/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs
+++ b/src/RustPlusBot.Features.Switches/Posting/DiscordSwitchChannelPoster.cs
@@ -1,15 +1,14 @@
using Discord;
-using Discord.WebSocket;
using Microsoft.Extensions.Logging;
using RustPlusBot.Discord.Posting;
namespace RustPlusBot.Features.Switches.Posting;
/// Posts/edits switch embeds in #switches by message id. Untested integration shim.
-/// The Discord socket client.
+/// The shared gated channel messenger.
/// The logger.
internal sealed class DiscordSwitchChannelPoster(
- DiscordSocketClient client,
+ DiscordChannelMessenger messenger,
ILogger logger) : ISwitchChannelPoster
{
///
@@ -19,6 +18,5 @@ internal sealed class DiscordSwitchChannelPoster(
Embed embed,
MessageComponent components,
CancellationToken cancellationToken)
- => DiscordChannelMessenger.EnsureAsync(client, channelId, messageId, embed, components, logger,
- cancellationToken);
+ => messenger.EnsureAsync(channelId, messageId, embed, components, logger, cancellationToken);
}
diff --git a/src/RustPlusBot.Features.Switches/Relaying/SwitchStateRelay.cs b/src/RustPlusBot.Features.Switches/Relaying/SwitchStateRelay.cs
index 28828283..8f4227ac 100644
--- a/src/RustPlusBot.Features.Switches/Relaying/SwitchStateRelay.cs
+++ b/src/RustPlusBot.Features.Switches/Relaying/SwitchStateRelay.cs
@@ -1,18 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
-using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Switches;
using RustPlusBot.Features.Switches.Posting;
using RustPlusBot.Features.Switches.Rendering;
using RustPlusBot.Features.Workspace.Locating;
-using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Switches;
using RustPlusBot.Persistence.Workspace;
namespace RustPlusBot.Features.Switches.Relaying;
-/// Keeps switch embeds in sync: live state changes re-render; a non-Connected server marks them unreachable.
+/// Keeps switch embeds in sync: live state changes re-render; a drop from Connected marks them unreachable.
/// Opens scopes for the scoped stores.
/// Resolves the #switches channel id.
/// Posts/edits switch embeds.
@@ -119,7 +116,7 @@ await RenderAsync(store, sw, sw.LastIsActive, evt.GuildId, evt.ServerId, culture
}
}
- /// Handles a connection-status change: a non-Connected server marks its switch embeds unreachable.
+ /// Handles a connection-status change: a drop from Connected marks its switch embeds unreachable.
/// The connection-status change.
/// A cancellation token.
/// A task that completes when every affected embed has been re-rendered.
@@ -128,18 +125,17 @@ public async Task HandleConnectionStatusAsync(
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(evt);
+ if (evt.IsConnected || !evt.WasConnected)
+ {
+ // Connected: the supervisor's prime path republishes real state — nothing to do.
+ // Never-connected in this process (boot, reconnect-loop repeats): keep the last-run
+ // embeds; only a drop from Connected sweeps them to unreachable.
+ return;
+ }
+
var scope = scopeFactory.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
- var connections = scope.ServiceProvider.GetRequiredService();
- var state = await connections.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken)
- .ConfigureAwait(false);
- if (state is { Status: ConnectionStatus.Connected })
- {
- // The supervisor's prime path republishes real state on connect; nothing to do here.
- return;
- }
-
var store = scope.ServiceProvider.GetRequiredService();
var switches = await store.ListByServerAsync(evt.GuildId, evt.ServerId, cancellationToken)
.ConfigureAwait(false);
diff --git a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs
index 80fc3888..c29c2904 100644
--- a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs
+++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs
@@ -68,24 +68,44 @@ public async Task StopAsync(CancellationToken cancellationToken)
}
}
- private async Task OnReadyAsync()
+ private Task OnReadyAsync()
{
+ // Ready fires on every gateway (re)connect; only heal once per process. 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-run.
if (_startupDone)
{
- return;
+ return Task.CompletedTask;
}
_startupDone = true;
- var scope = scopeFactory.CreateAsyncScope();
- await using (scope.ConfigureAwait(false))
+
+ // Healing sweeps every provisioned guild's channels over REST; doing it inline blocks the
+ // gateway task and stalls event dispatch, so offload it. Failures must be caught here —
+ // nothing awaits this.
+ _ = Task.Run(HealProvisionedGuildsAsync);
+ return Task.CompletedTask;
+ }
+
+ private async Task HealProvisionedGuildsAsync()
+ {
+ try
{
- var store = scope.ServiceProvider.GetRequiredService();
- var reconciler = scope.ServiceProvider.GetRequiredService();
- foreach (var guildId in await store.GetProvisionedGuildIdsAsync().ConfigureAwait(false))
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
{
- await reconciler.HealGuildAsync(guildId).ConfigureAwait(false);
+ var store = scope.ServiceProvider.GetRequiredService();
+ var reconciler = scope.ServiceProvider.GetRequiredService();
+ foreach (var guildId in await store.GetProvisionedGuildIdsAsync().ConfigureAwait(false))
+ {
+ await reconciler.HealGuildAsync(guildId).ConfigureAwait(false);
+ }
}
}
+ catch (Exception ex) // Broad catch is intentional: a faulting startup heal must not crash the host.
+ {
+ logger.LogError(ex, "Startup self-heal failed.");
+ }
}
private async Task OnChannelDestroyedAsync(SocketChannel channel)
diff --git a/tests/RustPlusBot.Discord.Tests/Posting/RenderCanonicalizerTests.cs b/tests/RustPlusBot.Discord.Tests/Posting/RenderCanonicalizerTests.cs
new file mode 100644
index 00000000..4848b479
--- /dev/null
+++ b/tests/RustPlusBot.Discord.Tests/Posting/RenderCanonicalizerTests.cs
@@ -0,0 +1,183 @@
+using Discord;
+using RustPlusBot.Discord.Posting;
+
+namespace RustPlusBot.Discord.Tests.Posting;
+
+public sealed class RenderCanonicalizerTests
+{
+ private static Embed BuildEmbed(
+ string title = "Switch",
+ string description = "On",
+ string footer = "id 42",
+ uint color = 0x00FF00)
+ => new EmbedBuilder()
+ .WithTitle(title)
+ .WithDescription(description)
+ .WithFooter(footer)
+ .WithColor(new Color(color))
+ .Build();
+
+ private static MessageComponent BuildComponents(string label = "On", bool disabled = false)
+ => new ComponentBuilder()
+ .WithButton(label, "sw:on:42", ButtonStyle.Success, disabled: disabled)
+ .Build();
+
+ ///
+ /// Neither a button nor a select menu: exercises AppendComponent's default (unmodeled kind)
+ /// branch for a child nested inside an ActionRow. TextInput is IInteractableComponent, so its
+ /// custom id should be captured too. ComponentBuilder (V1) rejects text inputs outright, so
+ /// this goes through ComponentBuilderV2, which is the only public-API path that allows it.
+ ///
+ /// The text input's custom id.
+ private static MessageComponent BuildTextInputRow(string customId = "ti:1")
+ => new ComponentBuilderV2()
+ .AddComponent(new ActionRowBuilder().AddComponent(new TextInputBuilder(customId)))
+ .Build();
+
+ ///
+ /// A top-level component that is not an ActionRow at all (a Components-V2 layout piece):
+ /// exercises the AppendComponents walk of non-row top-level components, and AppendComponent's
+ /// default branch for a kind that has no custom id.
+ ///
+ /// The text display's content.
+ private static MessageComponent BuildTextDisplay(string content = "hello")
+ => new ComponentBuilderV2()
+ .AddComponent(new TextDisplayBuilder(content))
+ .Build();
+
+ [Fact]
+ public void Identical_renders_produce_identical_canonical_strings()
+ {
+ var a = RenderCanonicalizer.Canonicalize(BuildEmbed(), BuildComponents());
+ var b = RenderCanonicalizer.Canonicalize(BuildEmbed(), BuildComponents());
+
+ Assert.Equal(a, b);
+ }
+
+ [Fact]
+ public void Different_description_changes_the_canonical_string()
+ {
+ var a = RenderCanonicalizer.Canonicalize(BuildEmbed(description: "On"), BuildComponents());
+ var b = RenderCanonicalizer.Canonicalize(BuildEmbed(description: "Off"), BuildComponents());
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void Different_color_changes_the_canonical_string()
+ {
+ var a = RenderCanonicalizer.Canonicalize(BuildEmbed(color: 0x00FF00), BuildComponents());
+ var b = RenderCanonicalizer.Canonicalize(BuildEmbed(color: 0xFF0000), BuildComponents());
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void Different_footer_changes_the_canonical_string()
+ {
+ var a = RenderCanonicalizer.Canonicalize(BuildEmbed(footer: "id 42"), BuildComponents());
+ var b = RenderCanonicalizer.Canonicalize(BuildEmbed(footer: "id 43"), BuildComponents());
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void Different_button_label_changes_the_canonical_string()
+ {
+ var a = RenderCanonicalizer.Canonicalize(BuildEmbed(), BuildComponents(label: "On"));
+ var b = RenderCanonicalizer.Canonicalize(BuildEmbed(), BuildComponents(label: "Off"));
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void Disabling_a_button_changes_the_canonical_string()
+ {
+ var a = RenderCanonicalizer.Canonicalize(BuildEmbed(), BuildComponents(disabled: false));
+ var b = RenderCanonicalizer.Canonicalize(BuildEmbed(), BuildComponents(disabled: true));
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void Adjacent_values_do_not_collide()
+ {
+ // Same concatenation, different boundaries: length-prefixing must keep these apart.
+ var a = RenderCanonicalizer.Canonicalize(BuildEmbed(title: "ab", description: "c"), components: null);
+ var b = RenderCanonicalizer.Canonicalize(BuildEmbed(title: "a", description: "bc"), components: null);
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void Fields_are_included()
+ {
+ var bare = new EmbedBuilder().WithTitle("T").Build();
+ var withField = new EmbedBuilder().WithTitle("T").AddField("Slots", "3/24", inline: true).Build();
+
+ Assert.NotEqual(
+ RenderCanonicalizer.Canonicalize(bare, components: null),
+ RenderCanonicalizer.Canonicalize(withField, components: null));
+ }
+
+ [Fact]
+ public void Null_components_differ_from_button_components()
+ {
+ Assert.NotEqual(
+ RenderCanonicalizer.Canonicalize(BuildEmbed(), components: null),
+ RenderCanonicalizer.Canonicalize(BuildEmbed(), BuildComponents()));
+ }
+
+ [Fact]
+ public void Select_menu_options_are_included()
+ {
+ static MessageComponent Menu(string optionLabel) => new ComponentBuilder()
+ .WithSelectMenu("menu:1", [
+ new SelectMenuOptionBuilder().WithLabel(optionLabel).WithValue("v1")
+ ])
+ .Build();
+
+ Assert.NotEqual(
+ RenderCanonicalizer.Canonicalize(embed: null, Menu("Alpha")),
+ RenderCanonicalizer.Canonicalize(embed: null, Menu("Beta")));
+ }
+
+ [Fact]
+ public void Select_menu_option_emote_change_changes_the_canonical_string()
+ {
+ static MessageComponent Menu(string emoji) => new ComponentBuilder()
+ .WithSelectMenu("menu:1", [
+ new SelectMenuOptionBuilder().WithLabel("A").WithValue("v1").WithEmote(new Emoji(emoji))
+ ])
+ .Build();
+
+ Assert.NotEqual(
+ RenderCanonicalizer.Canonicalize(embed: null, Menu("🔥")),
+ RenderCanonicalizer.Canonicalize(embed: null, Menu("💧")));
+ }
+
+ [Fact]
+ public void Unmodeled_component_kind_differs_from_no_components()
+ {
+ Assert.NotEqual(
+ RenderCanonicalizer.Canonicalize(embed: null, components: null),
+ RenderCanonicalizer.Canonicalize(embed: null, BuildTextInputRow()));
+ }
+
+ [Fact]
+ public void Unmodeled_component_custom_id_change_changes_the_canonical_string()
+ {
+ var a = RenderCanonicalizer.Canonicalize(embed: null, BuildTextInputRow("ti:1"));
+ var b = RenderCanonicalizer.Canonicalize(embed: null, BuildTextInputRow("ti:2"));
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void Top_level_non_action_row_component_is_included()
+ {
+ Assert.NotEqual(
+ RenderCanonicalizer.Canonicalize(embed: null, components: null),
+ RenderCanonicalizer.Canonicalize(embed: null, BuildTextDisplay()));
+ }
+}
diff --git a/tests/RustPlusBot.Discord.Tests/Posting/RenderGateTests.cs b/tests/RustPlusBot.Discord.Tests/Posting/RenderGateTests.cs
new file mode 100644
index 00000000..1ed17633
--- /dev/null
+++ b/tests/RustPlusBot.Discord.Tests/Posting/RenderGateTests.cs
@@ -0,0 +1,62 @@
+using RustPlusBot.Discord.Posting;
+
+namespace RustPlusBot.Discord.Tests.Posting;
+
+public sealed class RenderGateTests
+{
+ [Fact]
+ public void First_render_for_a_message_should_send()
+ {
+ var gate = new RenderGate();
+
+ Assert.True(gate.ShouldSend(900UL, "render-a"));
+ }
+
+ [Fact]
+ public void Committed_identical_render_should_not_send()
+ {
+ var gate = new RenderGate();
+ gate.Commit(900UL, "render-a");
+
+ Assert.False(gate.ShouldSend(900UL, "render-a"));
+ }
+
+ [Fact]
+ public void Committed_then_different_render_should_send()
+ {
+ var gate = new RenderGate();
+ gate.Commit(900UL, "render-a");
+
+ Assert.True(gate.ShouldSend(900UL, "render-b"));
+ }
+
+ [Fact]
+ public void Invalidate_forces_the_next_identical_render_to_send()
+ {
+ var gate = new RenderGate();
+ gate.Commit(900UL, "render-a");
+ gate.Invalidate(900UL);
+
+ Assert.True(gate.ShouldSend(900UL, "render-a"));
+ }
+
+ [Fact]
+ public void Invalidate_on_an_untracked_message_is_a_no_op()
+ {
+ var gate = new RenderGate();
+
+ gate.Invalidate(901UL);
+
+ Assert.True(gate.ShouldSend(901UL, "render-a"));
+ }
+
+ [Fact]
+ public void Messages_are_tracked_independently()
+ {
+ var gate = new RenderGate();
+ gate.Commit(900UL, "render-a");
+
+ Assert.True(gate.ShouldSend(901UL, "render-a"));
+ Assert.False(gate.ShouldSend(900UL, "render-a"));
+ }
+}
diff --git a/tests/RustPlusBot.Discord.Tests/RustPlusBot.Discord.Tests.csproj b/tests/RustPlusBot.Discord.Tests/RustPlusBot.Discord.Tests.csproj
new file mode 100644
index 00000000..bfafd8d9
--- /dev/null
+++ b/tests/RustPlusBot.Discord.Tests/RustPlusBot.Discord.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs
index 05079c4a..d054a256 100644
--- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs
@@ -9,6 +9,7 @@
using RustPlusBot.Features.Alarms.Posting;
using RustPlusBot.Features.Alarms.Relaying;
using RustPlusBot.Features.Alarms.Rendering;
+using RustPlusBot.Discord.Posting;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Localization;
@@ -55,6 +56,8 @@ public void AddAlarms_resolves_without_captive_dependency_errors()
// Discord
var discordConfig = new DiscordSocketConfig();
services.AddSingleton(new DiscordSocketClient(discordConfig));
+ services.AddSingleton();
+ services.AddSingleton();
// Scoped stores from Persistence
services.AddScoped(_ => Substitute.For());
diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs
index 450c9145..46eba471 100644
--- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs
+++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs
@@ -6,7 +6,6 @@
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Domain.Alarms;
-using RustPlusBot.Domain.Connections;
using RustPlusBot.Features.Alarms.Posting;
using RustPlusBot.Features.Alarms.Relaying;
using RustPlusBot.Features.Alarms.Rendering;
@@ -14,7 +13,6 @@
using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Localization;
using RustPlusBot.Persistence.Alarms;
-using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Workspace;
namespace RustPlusBot.Features.Alarms.Tests;
@@ -27,13 +25,11 @@ public sealed class AlarmStateRelayTests
private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL)
{
var store = Substitute.For();
- var connections = Substitute.For();
var workspace = Substitute.For();
workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en");
var services = new ServiceCollection();
services.AddScoped(_ => store);
- services.AddScoped(_ => connections);
services.AddScoped(_ => workspace);
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService();
@@ -67,7 +63,7 @@ private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL
clock,
NullLogger.Instance);
- return new Harness(relay, store, refresher, poster, teamChatSender, connections);
+ return new Harness(relay, store, refresher, poster, teamChatSender);
}
// ──────────────────────────────────────────────────────────────────────────
@@ -353,19 +349,13 @@ await h.Refresher.Received(1)
// Connection status
// ──────────────────────────────────────────────────────────────────────────
- /// Non-Connected server marks each alarm's embed as unreachable.
+ /// Drop from Connected marks each alarm's embed as unreachable.
[Fact]
public async Task ConnectionStatus_not_connected_refreshes_all_alarms_unreachable()
{
var serverId = Guid.NewGuid();
var h = Create();
- h.Connections.GetStateAsync(10UL, serverId, Arg.Any())
- .Returns(new ConnectionState
- {
- GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Unreachable,
- });
-
h.Store.ListByServerAsync(10UL, serverId, Arg.Any())
.Returns(
[
@@ -380,7 +370,8 @@ public async Task ConnectionStatus_not_connected_refreshes_all_alarms_unreachabl
]);
await h.Relay.HandleConnectionStatusAsync(
- new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None);
+ new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: false, WasConnected: true),
+ CancellationToken.None);
await h.Refresher.Received(1).RefreshAsync(
Arg.Is(a => a.EntityId == 42UL), unreachable: true, Arg.Any());
@@ -395,14 +386,9 @@ public async Task ConnectionStatus_connected_does_nothing()
var serverId = Guid.NewGuid();
var h = Create();
- h.Connections.GetStateAsync(10UL, serverId, Arg.Any())
- .Returns(new ConnectionState
- {
- GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Connected,
- });
-
await h.Relay.HandleConnectionStatusAsync(
- new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None);
+ new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: true, WasConnected: true),
+ CancellationToken.None);
await h.Refresher.DidNotReceive().RefreshAsync(
Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
@@ -410,6 +396,30 @@ await h.Refresher.DidNotReceive()
.RefreshAsync(Arg.Any(), Arg.Any(), Arg.Any());
}
+ /// Boot/reconnect-loop statuses (never Connected in this process) must not sweep — embeds keep their last-run state until the prime republishes.
+ [Fact]
+ public async Task ConnectionStatus_boot_without_prior_connection_does_not_sweep()
+ {
+ var serverId = Guid.NewGuid();
+ var h = Create();
+
+ h.Store.ListByServerAsync(10UL, serverId, Arg.Any())
+ .Returns(
+ [
+ new SmartAlarm
+ {
+ GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "A"
+ },
+ ]);
+
+ await h.Relay.HandleConnectionStatusAsync(
+ new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: false, WasConnected: false),
+ CancellationToken.None);
+
+ await h.Refresher.DidNotReceive()
+ .RefreshAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
// ──────────────────────────────────────────────────────────────────────────
// Reachability changed
// ──────────────────────────────────────────────────────────────────────────
@@ -462,6 +472,5 @@ private sealed record Harness(
IAlarmStore Store,
IAlarmRefresher Refresher,
IAlarmChannelPoster Poster,
- IBotTeamChatSender TeamChatSender,
- IConnectionStore Connections);
+ IBotTeamChatSender TeamChatSender);
}
diff --git a/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs
index 635bb330..360f22d9 100644
--- a/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs
+++ b/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs
@@ -5,7 +5,6 @@
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Domain.Alarms;
-using RustPlusBot.Domain.Connections;
using RustPlusBot.Features.Alarms.Hosting;
using RustPlusBot.Features.Alarms.Pairing;
using RustPlusBot.Features.Alarms.Posting;
@@ -15,7 +14,6 @@
using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Localization;
using RustPlusBot.Persistence.Alarms;
-using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Workspace;
namespace RustPlusBot.Features.Alarms.Tests.Hosting;
@@ -27,13 +25,11 @@ public sealed class AlarmsHostedServiceTests
private static Harness Create()
{
var store = Substitute.For();
- var connections = Substitute.For();
var workspace = Substitute.For();
workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en");
var services = new ServiceCollection();
services.AddScoped(_ => store);
- services.AddScoped(_ => connections);
services.AddScoped(_ => workspace);
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService();
@@ -76,7 +72,7 @@ private static Harness Create()
relay,
NullLogger.Instance);
- return new Harness(service, bus, store, connections, refresher, relayPoster, pairingLocator, pairingPoster);
+ return new Harness(service, bus, store, refresher, relayPoster, pairingLocator, pairingPoster);
}
[Fact]
@@ -178,11 +174,6 @@ public async Task ConnectionStatusChangedEvent_non_connected_routes_to_relay_and
await h.Service.StartAsync(default);
var serverId = Guid.NewGuid();
- h.Connections.GetStateAsync(10UL, serverId, Arg.Any())
- .Returns(new ConnectionState
- {
- GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Unreachable,
- });
h.Store.ListByServerAsync(10UL, serverId, Arg.Any())
.Returns(
[
@@ -197,7 +188,8 @@ public async Task ConnectionStatusChangedEvent_non_connected_routes_to_relay_and
&& !h.Refresher.ReceivedCalls().Any(c =>
c.GetMethodInfo().Name == nameof(IAlarmRefresher.RefreshAsync)))
{
- await h.Bus.PublishAsync(new ConnectionStatusChangedEvent(10UL, serverId));
+ await h.Bus.PublishAsync(
+ new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: false, WasConnected: true));
await Task.Delay(20);
}
@@ -263,7 +255,6 @@ private sealed record Harness(
AlarmsHostedService Service,
InMemoryEventBus Bus,
IAlarmStore Store,
- IConnectionStore Connections,
IAlarmRefresher Refresher,
IAlarmChannelPoster Poster,
IAlarmChannelLocator PairingLocator,
diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs
index cb210fcf..22f622ac 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs
@@ -231,6 +231,72 @@ public async Task Heartbeat_Unreachable_ReconnectsAndRecovers()
Assert.True(source.CreateCount >= 2);
}
+ ///
+ /// WasConnected must be computed from in-process state (the DB status survives restarts and
+ /// would claim Connected at boot): statuses before the first Connected carry false; the drop
+ /// after a Connected carries true.
+ ///
+ [Fact]
+ public async Task StatusEvents_CarryWasConnected_OnlyAfterAConnectedDrop()
+ {
+ var source = new FakeRustSocketSource();
+ source.EnqueueConnect(SocketConnectOutcome.Connected);
+ source.EnqueueHeartbeat(HeartbeatResult.Ok(2)); // first heartbeat -> Connected
+ source.EnqueueHeartbeat(HeartbeatResult.Unreachable); // next heartbeat -> drop
+ source.EnqueueConnect(SocketConnectOutcome.Connected); // reconnect
+ source.EnqueueHeartbeat(HeartbeatResult.Ok(4));
+ await using var h = CreateHarness(source);
+ var (serverId, _, _) = await SeedAsync(h.Provider);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ var events = new List();
+ _ = Task.Run(async () =>
+ {
+ await foreach (var e in h.Bus.SubscribeAsync(cts.Token))
+ {
+ lock (events)
+ {
+ events.Add(e);
+ }
+ }
+ }, cts.Token);
+
+ await h.Supervisor.EnsureConnectionAsync(10UL, serverId);
+ var recovered = await WaitForStateAsync(
+ h.Provider, serverId, s => s.Status == ConnectionStatus.Connected && s.PlayerCount == 4);
+ Assert.NotNull(recovered);
+
+ // The bus delivers asynchronously; wait until the collector has seen the drop.
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(10);
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ lock (events)
+ {
+ if (events.Any(e => !e.IsConnected && e.WasConnected))
+ {
+ break;
+ }
+ }
+
+ await Task.Delay(15);
+ }
+
+ await cts.CancelAsync();
+ ConnectionStatusChangedEvent[] snapshot;
+ lock (events)
+ {
+ snapshot = [.. events];
+ }
+
+ var firstConnected = Array.FindIndex(snapshot, e => e.IsConnected);
+ Assert.True(firstConnected >= 0, "expected a Connected status event");
+ Assert.All(snapshot.Take(firstConnected), e => Assert.False(e.WasConnected));
+ var drop = Array.FindIndex(
+ snapshot, firstConnected, snapshot.Length - firstConnected, e => !e.IsConnected);
+ Assert.True(drop > firstConnected, "expected a drop event after Connected");
+ Assert.True(snapshot[drop].WasConnected);
+ }
+
[Fact]
public async Task StartAll_StartsAConnectionPerConnectableServer()
{
diff --git a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs
index fd56348d..8466eb3b 100644
--- a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs
@@ -5,6 +5,7 @@
using NSubstitute;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Discord.Posting;
using RustPlusBot.Features.Connections;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.Hosting;
@@ -39,6 +40,8 @@ private static ServiceProvider BuildProvider()
services.AddSingleton(Substitute.For());
services.AddSingleton();
services.AddSingleton(new DiscordSocketClient());
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton(Substitute.For());
services.AddScoped(_ => Substitute.For());
services.AddScoped(_ => Substitute.For());
diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs
index 51d9312a..804a7761 100644
--- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs
@@ -3,6 +3,7 @@
using Microsoft.Extensions.Hosting;
using NSubstitute;
using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Discord.Posting;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Players;
using RustPlusBot.Features.Players.Hosting;
@@ -28,6 +29,8 @@ private static ServiceProvider BuildProvider()
services.AddLogging();
services.AddSingleton();
services.AddSingleton(new DiscordSocketClient());
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton(Substitute.For());
services.AddScoped(_ => Substitute.For());
services.AddSingleton(Substitute.For());
diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs
index 3587eaaa..281aa6b0 100644
--- a/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs
+++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs
@@ -3,7 +3,6 @@
using NSubstitute;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.StorageMonitors;
using RustPlusBot.Features.ItemData.Naming;
using RustPlusBot.Features.StorageMonitors.Hosting;
@@ -13,7 +12,6 @@
using RustPlusBot.Features.StorageMonitors.Rendering;
using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Localization;
-using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.StorageMonitors;
using RustPlusBot.Persistence.Workspace;
@@ -26,13 +24,11 @@ public sealed class StorageMonitorsHostedServiceTests
private static Harness Create()
{
var store = Substitute.For();
- var connections = Substitute.For();
var workspace = Substitute.For();
workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en");
var services = new ServiceCollection();
services.AddScoped(_ => store);
- services.AddScoped(_ => connections);
services.AddScoped(_ => workspace);
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService();
@@ -66,7 +62,7 @@ private static Harness Create()
relay,
NullLogger.Instance);
- return new Harness(service, bus, store, connections, relayPoster, relayLocator, pairingLocator, pairingPoster);
+ return new Harness(service, bus, store, relayPoster, relayLocator, pairingLocator, pairingPoster);
}
[Fact]
@@ -137,11 +133,6 @@ public async Task ConnectionStatusChangedEvent_non_connected_routes_to_relay_and
await h.Service.StartAsync(default);
var serverId = Guid.NewGuid();
- h.Connections.GetStateAsync(Guild, serverId, Arg.Any())
- .Returns(new ConnectionState
- {
- GuildId = Guild, RustServerId = serverId, Status = ConnectionStatus.Unreachable,
- });
h.Store.ListByServerAsync(Guild, serverId, Arg.Any())
.Returns(
[
@@ -160,7 +151,8 @@ public async Task ConnectionStatusChangedEvent_non_connected_routes_to_relay_and
&& !h.Poster.ReceivedCalls().Any(c =>
c.GetMethodInfo().Name == nameof(IStorageMonitorChannelPoster.EnsureAsync)))
{
- await h.Bus.PublishAsync(new ConnectionStatusChangedEvent(Guild, serverId));
+ await h.Bus.PublishAsync(
+ new ConnectionStatusChangedEvent(Guild, serverId, IsConnected: false, WasConnected: true));
await Task.Delay(20);
}
@@ -241,7 +233,6 @@ private sealed record Harness(
StorageMonitorsHostedService Service,
InMemoryEventBus Bus,
IStorageMonitorStore Store,
- IConnectionStore Connections,
IStorageMonitorChannelPoster Poster,
IStorageMonitorChannelLocator Locator,
IStorageMonitorChannelLocator PairingLocator,
diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs
index 78e41d4d..69129ee5 100644
--- a/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs
+++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs
@@ -2,7 +2,6 @@
using NSubstitute;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.StorageMonitors;
using RustPlusBot.Features.ItemData.Naming;
using RustPlusBot.Features.StorageMonitors.Posting;
@@ -24,13 +23,11 @@ public sealed class StorageMonitorStateRelayTests
private static Harness Create()
{
var store = Substitute.For();
- var connections = Substitute.For();
var workspace = Substitute.For();
workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en");
var services = new ServiceCollection();
services.AddScoped(_ => store);
- services.AddScoped(_ => connections);
services.AddScoped(_ => workspace);
var provider = services.BuildServiceProvider();
@@ -49,7 +46,7 @@ private static Harness Create()
var relay = new StorageMonitorStateRelay(
provider.GetRequiredService(), locator, poster, renderer, query);
- return new Harness(relay, store, poster, connections, query);
+ return new Harness(relay, store, poster, query);
}
[Fact]
@@ -95,11 +92,6 @@ await h.Poster.Received(1).EnsureAsync(555UL, Arg.Any(),
public async Task HandleConnectionStatusAsync_NotConnected_PostsUnreachable()
{
var h = Create();
- h.Connections.GetStateAsync(Guild, Server, Arg.Any())
- .Returns(new ConnectionState
- {
- GuildId = Guild, RustServerId = Server, Status = ConnectionStatus.Unreachable,
- });
h.Store.ListByServerAsync(Guild, Server, Arg.Any())
.Returns(
[
@@ -114,7 +106,8 @@ public async Task HandleConnectionStatusAsync_NotConnected_PostsUnreachable()
]);
await h.Relay.HandleConnectionStatusAsync(
- new ConnectionStatusChangedEvent(Guild, Server), CancellationToken.None);
+ new ConnectionStatusChangedEvent(Guild, Server, IsConnected: false, WasConnected: true),
+ CancellationToken.None);
await h.Poster.Received(1).EnsureAsync(555UL, Arg.Any(),
Arg.Any(), Arg.Any(),
@@ -125,14 +118,37 @@ await h.Poster.Received(1).EnsureAsync(555UL, Arg.Any(),
public async Task HandleConnectionStatusAsync_Connected_DoesNothing()
{
var h = Create();
- h.Connections.GetStateAsync(Guild, Server, Arg.Any())
- .Returns(new ConnectionState
- {
- GuildId = Guild, RustServerId = Server, Status = ConnectionStatus.Connected,
- });
await h.Relay.HandleConnectionStatusAsync(
- new ConnectionStatusChangedEvent(Guild, Server), CancellationToken.None);
+ new ConnectionStatusChangedEvent(Guild, Server, IsConnected: true, WasConnected: true),
+ CancellationToken.None);
+
+ await h.Poster.DidNotReceive().EnsureAsync(Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(),
+ Arg.Any());
+ }
+
+ /// Boot/reconnect-loop statuses (never Connected in this process) must not sweep — embeds keep their last-run state until the prime republishes.
+ [Fact]
+ public async Task HandleConnectionStatusAsync_BootWithoutPriorConnection_DoesNothing()
+ {
+ var h = Create();
+ h.Store.ListByServerAsync(Guild, Server, Arg.Any())
+ .Returns(
+ [
+ new SmartStorageMonitor
+ {
+ GuildId = Guild,
+ ServerId = Server,
+ EntityId = 7UL,
+ Name = "TC",
+ MessageId = null,
+ },
+ ]);
+
+ await h.Relay.HandleConnectionStatusAsync(
+ new ConnectionStatusChangedEvent(Guild, Server, IsConnected: false, WasConnected: false),
+ CancellationToken.None);
await h.Poster.DidNotReceive().EnsureAsync(Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(),
@@ -241,6 +257,5 @@ private sealed record Harness(
StorageMonitorStateRelay Relay,
IStorageMonitorStore Store,
IStorageMonitorChannelPoster Poster,
- IConnectionStore Connections,
IRustServerQuery Query);
}
diff --git a/tests/RustPlusBot.Features.Switches.Tests/Hosting/SwitchesHostedServiceTests.cs b/tests/RustPlusBot.Features.Switches.Tests/Hosting/SwitchesHostedServiceTests.cs
index 7b506f76..3215d8df 100644
--- a/tests/RustPlusBot.Features.Switches.Tests/Hosting/SwitchesHostedServiceTests.cs
+++ b/tests/RustPlusBot.Features.Switches.Tests/Hosting/SwitchesHostedServiceTests.cs
@@ -3,7 +3,6 @@
using NSubstitute;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Switches;
using RustPlusBot.Features.Switches.Hosting;
using RustPlusBot.Features.Switches.Pairing;
@@ -12,7 +11,6 @@
using RustPlusBot.Features.Switches.Rendering;
using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Localization;
-using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Switches;
using RustPlusBot.Persistence.Workspace;
@@ -23,13 +21,11 @@ public sealed class SwitchesHostedServiceTests
private static Harness Create()
{
var store = Substitute.For();
- var connections = Substitute.For();
var workspace = Substitute.For();
workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en");
var services = new ServiceCollection();
services.AddScoped(_ => store);
- services.AddScoped(_ => connections);
services.AddScoped(_ => workspace);
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService();
@@ -60,7 +56,7 @@ private static Harness Create()
relay,
NullLogger.Instance);
- return new Harness(service, bus, store, connections, relayPoster, relayLocator, pairingLocator, pairingPoster);
+ return new Harness(service, bus, store, relayPoster, relayLocator, pairingLocator, pairingPoster);
}
[Fact]
@@ -129,11 +125,6 @@ public async Task ConnectionStatusChangedEvent_non_connected_routes_to_relay_and
await h.Service.StartAsync(default);
var serverId = Guid.NewGuid();
- h.Connections.GetStateAsync(10UL, serverId, Arg.Any())
- .Returns(new ConnectionState
- {
- GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Unreachable,
- });
h.Store.ListByServerAsync(10UL, serverId, Arg.Any())
.Returns(
[
@@ -152,7 +143,8 @@ public async Task ConnectionStatusChangedEvent_non_connected_routes_to_relay_and
&& !h.Poster.ReceivedCalls().Any(c =>
c.GetMethodInfo().Name == nameof(ISwitchChannelPoster.EnsureAsync)))
{
- await h.Bus.PublishAsync(new ConnectionStatusChangedEvent(10UL, serverId));
+ await h.Bus.PublishAsync(
+ new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: false, WasConnected: true));
await Task.Delay(20);
}
@@ -265,7 +257,6 @@ private sealed record Harness(
SwitchesHostedService Service,
InMemoryEventBus Bus,
ISwitchStore Store,
- IConnectionStore Connections,
ISwitchChannelPoster Poster,
ISwitchChannelLocator Locator,
ISwitchChannelLocator PairingLocator,
diff --git a/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs b/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs
index 2d727b36..ba4ffe7b 100644
--- a/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs
+++ b/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs
@@ -2,14 +2,12 @@
using NSubstitute;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
-using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Switches;
using RustPlusBot.Features.Switches.Posting;
using RustPlusBot.Features.Switches.Relaying;
using RustPlusBot.Features.Switches.Rendering;
using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Localization;
-using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Switches;
using RustPlusBot.Persistence.Workspace;
@@ -20,13 +18,11 @@ public sealed class SwitchStateRelayTests
private static Harness Create()
{
var store = Substitute.For();
- var connections = Substitute.For();
var workspace = Substitute.For();
workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en");
var services = new ServiceCollection();
services.AddScoped(_ => store);
- services.AddScoped(_ => connections);
services.AddScoped(_ => workspace);
var provider = services.BuildServiceProvider();
@@ -39,7 +35,7 @@ private static Harness Create()
var relay = new SwitchStateRelay(provider.GetRequiredService(), locator, poster,
renderer);
- return new Harness(relay, store, poster, connections);
+ return new Harness(relay, store, poster);
}
[Fact]
@@ -70,11 +66,6 @@ public async Task ConnectionStatus_not_connected_marks_switches_unreachable()
{
var h = Create();
var serverId = Guid.NewGuid();
- h.Connections.GetStateAsync(10UL, serverId, Arg.Any())
- .Returns(new ConnectionState
- {
- GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Unreachable
- });
h.Store.ListByServerAsync(10UL, serverId, Arg.Any())
.Returns(
[
@@ -89,7 +80,8 @@ public async Task ConnectionStatus_not_connected_marks_switches_unreachable()
]);
await h.Relay.HandleConnectionStatusAsync(
- new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None);
+ new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: false, WasConnected: true),
+ CancellationToken.None);
await h.Poster.Received(1).EnsureAsync(777UL, 900UL, Arg.Any(),
Arg.Any(), Arg.Any());
@@ -100,14 +92,38 @@ public async Task ConnectionStatus_connected_does_nothing()
{
var h = Create();
var serverId = Guid.NewGuid();
- h.Connections.GetStateAsync(10UL, serverId, Arg.Any())
- .Returns(new ConnectionState
- {
- GuildId = 10UL, RustServerId = serverId, Status = ConnectionStatus.Connected
- });
await h.Relay.HandleConnectionStatusAsync(
- new ConnectionStatusChangedEvent(10UL, serverId), CancellationToken.None);
+ new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: true, WasConnected: true),
+ CancellationToken.None);
+
+ await h.Poster.DidNotReceive().EnsureAsync(Arg.Any(), Arg.Any(),
+ Arg.Any(),
+ Arg.Any(), Arg.Any());
+ }
+
+ /// Boot/reconnect-loop statuses (never Connected in this process) must not sweep — embeds keep their last-run state until the prime republishes.
+ [Fact]
+ public async Task ConnectionStatus_boot_without_prior_connection_does_not_sweep()
+ {
+ var h = Create();
+ var serverId = Guid.NewGuid();
+ h.Store.ListByServerAsync(10UL, serverId, Arg.Any())
+ .Returns(
+ [
+ new SmartSwitch
+ {
+ GuildId = 10UL,
+ ServerId = serverId,
+ EntityId = 42UL,
+ Name = "G",
+ MessageId = 900UL
+ }
+ ]);
+
+ await h.Relay.HandleConnectionStatusAsync(
+ new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: false, WasConnected: false),
+ CancellationToken.None);
await h.Poster.DidNotReceive().EnsureAsync(Arg.Any(), Arg.Any(),
Arg.Any(),
@@ -203,6 +219,5 @@ await h.Poster.Received(1).EnsureAsync(777UL, 900UL, Arg.Any
c.GetMethodInfo().Name == nameof(IWorkspaceReconciler.ReconcileServerAsync)))
{
- await bus.PublishAsync(new ConnectionStatusChangedEvent(10UL, serverId));
+ await bus.PublishAsync(
+ new ConnectionStatusChangedEvent(10UL, serverId, IsConnected: false, WasConnected: false));
await Task.Delay(20);
}