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); }