diff --git a/Directory.Packages.props b/Directory.Packages.props
index 05408d4a..840128b0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,6 +11,7 @@
+
diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx
index 6a828257..52903ff4 100644
--- a/RustPlusBot.slnx
+++ b/RustPlusBot.slnx
@@ -4,12 +4,14 @@
+
+
diff --git a/src/RustPlusBot.Abstractions/Events/ConnectionStatusChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/ConnectionStatusChangedEvent.cs
new file mode 100644
index 00000000..9c23a391
--- /dev/null
+++ b/src/RustPlusBot.Abstractions/Events/ConnectionStatusChangedEvent.cs
@@ -0,0 +1,6 @@
+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);
diff --git a/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs b/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs
index 9f36e53a..d215e0d7 100644
--- a/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@
using Discord.Interactions;
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Discord.Notifications;
namespace RustPlusBot.Discord;
@@ -28,6 +29,7 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services)
{
DefaultRunMode = RunMode.Async
}));
+ services.AddSingleton();
services.AddHostedService();
return services;
diff --git a/src/RustPlusBot.Discord/Notifications/DiscordUserDmSender.cs b/src/RustPlusBot.Discord/Notifications/DiscordUserDmSender.cs
new file mode 100644
index 00000000..ce34674a
--- /dev/null
+++ b/src/RustPlusBot.Discord/Notifications/DiscordUserDmSender.cs
@@ -0,0 +1,44 @@
+using System.Diagnostics.CodeAnalysis;
+using Discord.WebSocket;
+using Microsoft.Extensions.Logging;
+
+namespace RustPlusBot.Discord.Notifications;
+
+/// Discord-backed .
+/// The socket client.
+/// The logger.
+[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes",
+ Justification = "Instantiated by the DI container via IUserDmSender registration.")]
+internal sealed partial class DiscordUserDmSender(DiscordSocketClient client, ILogger logger)
+ : IUserDmSender
+{
+ ///
+ public async Task SendAsync(ulong userId, string message, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // Discord.Net uses RequestOptions, not CancellationToken, for per-call cancellation, so the token is not forwarded.
+ var user = await client.GetUserAsync(userId).ConfigureAwait(false);
+ if (user is null)
+ {
+ LogUserNotFound(logger, userId);
+ return;
+ }
+
+ var dmChannel = await user.CreateDMChannelAsync().ConfigureAwait(false);
+ await dmChannel.SendMessageAsync(message).ConfigureAwait(false);
+ }
+#pragma warning disable CA1031 // Broad catch is intentional: a closed DM (or any send failure) must not break callers.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogDmFailed(logger, ex, userId);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Cannot DM user {UserId}: user not found.")]
+ private static partial void LogUserNotFound(ILogger logger, ulong userId);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to DM user {UserId}.")]
+ private static partial void LogDmFailed(ILogger logger, Exception ex, ulong userId);
+}
diff --git a/src/RustPlusBot.Discord/Notifications/IUserDmSender.cs b/src/RustPlusBot.Discord/Notifications/IUserDmSender.cs
new file mode 100644
index 00000000..eef127e7
--- /dev/null
+++ b/src/RustPlusBot.Discord/Notifications/IUserDmSender.cs
@@ -0,0 +1,12 @@
+namespace RustPlusBot.Discord.Notifications;
+
+/// Sends a direct message to a Discord user. A closed DM (or any send failure) is swallowed and logged.
+public interface IUserDmSender
+{
+ /// Best-effort DM to ; never throws.
+ /// The Discord user snowflake.
+ /// The message text.
+ /// A cancellation token.
+ /// A task that completes when the send was attempted.
+ Task SendAsync(ulong userId, string message, CancellationToken cancellationToken = default);
+}
diff --git a/src/RustPlusBot.Domain/Connections/ConnectionState.cs b/src/RustPlusBot.Domain/Connections/ConnectionState.cs
index bcfe4dd5..80205042 100644
--- a/src/RustPlusBot.Domain/Connections/ConnectionState.cs
+++ b/src/RustPlusBot.Domain/Connections/ConnectionState.cs
@@ -1,6 +1,6 @@
namespace RustPlusBot.Domain.Connections;
-/// Persisted last-known connection state per server, so the active identity survives restarts.
+/// Persisted last-known connection state per server, so the active identity and status survive restarts.
public sealed class ConnectionState
{
/// The server this state belongs to (primary key, one row per server).
@@ -12,8 +12,11 @@ public sealed class ConnectionState
/// The credential currently selected as active, if any.
public Guid? ActiveCredentialId { get; set; }
- /// Whether the connection was healthy at last check.
- public bool IsHealthy { get; set; }
+ /// The live-connection status.
+ public ConnectionStatus Status { get; set; }
+
+ /// Last heartbeat player count, or null if unknown.
+ public int? PlayerCount { get; set; }
/// When the state was last updated (UTC).
public DateTimeOffset UpdatedAt { get; set; }
diff --git a/src/RustPlusBot.Domain/Connections/ConnectionStatus.cs b/src/RustPlusBot.Domain/Connections/ConnectionStatus.cs
new file mode 100644
index 00000000..cc828686
--- /dev/null
+++ b/src/RustPlusBot.Domain/Connections/ConnectionStatus.cs
@@ -0,0 +1,17 @@
+namespace RustPlusBot.Domain.Connections;
+
+/// Live-connection lifecycle state for a server's socket.
+public enum ConnectionStatus
+{
+ /// Attempting to connect (initial, after a swap, or after a drop).
+ Connecting = 0,
+
+ /// Socket up and the last heartbeat was healthy.
+ Connected = 1,
+
+ /// A valid active credential exists but the server is not answering; retrying with backoff.
+ Unreachable = 2,
+
+ /// No eligible credential (pool empty or every credential is Invalid).
+ NoCredentials = 3,
+}
diff --git a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs
new file mode 100644
index 00000000..671c2933
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs
@@ -0,0 +1,20 @@
+namespace RustPlusBot.Features.Connections;
+
+/// Connection feature configuration, bound from the "Connections" config section.
+public sealed class ConnectionOptions
+{
+ /// How long to wait for a socket to connect before treating the server as unreachable.
+ public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ /// First delay before retrying after an unreachable result.
+ public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromSeconds(5);
+
+ /// Cap on the exponential backoff between reconnect attempts.
+ public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromMinutes(5);
+
+ /// How often to send a heartbeat on a connected socket.
+ public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(60);
+
+ /// How long a single heartbeat may take before the socket is considered unreachable.
+ public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(10);
+}
diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
new file mode 100644
index 00000000..b82a2093
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Discord;
+using RustPlusBot.Features.Connections.Hosting;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Connections.Supervisor;
+
+namespace RustPlusBot.Features.Connections;
+
+/// DI registration for the live-connection feature.
+public static class ConnectionServiceCollectionExtensions
+{
+ /// Registers the socket source, supervisor, module seam, and hosted service.
+ /// The service collection to add to.
+ /// The same service collection, for chaining.
+ public static IServiceCollection AddConnections(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Contribute this assembly's interaction modules to the Discord layer.
+ services.AddSingleton(new InteractionModuleAssembly(typeof(ConnectionServiceCollectionExtensions).Assembly));
+
+ services.AddHostedService();
+
+ return services;
+ }
+}
diff --git a/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs b/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs
new file mode 100644
index 00000000..8c4fcefb
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs
@@ -0,0 +1,81 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Supervisor;
+
+namespace RustPlusBot.Features.Connections.Hosting;
+
+/// Drives the connection supervisor: start all on startup, react to server registration, stop on shutdown.
+/// The connection supervisor.
+/// The in-process event bus.
+/// The logger.
+internal sealed partial class ConnectionHostedService(
+ IConnectionSupervisor supervisor,
+ IEventBus eventBus,
+ ILogger logger) : IHostedService, IDisposable
+{
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _eventLoop;
+
+ ///
+ public void Dispose() => _cts.Dispose();
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _eventLoop = Task.Run(() => RunAsync(_cts.Token), CancellationToken.None);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ await _cts.CancelAsync().ConfigureAwait(false);
+ if (_eventLoop is not null)
+ {
+ try
+ {
+#pragma warning disable VSTHRD003 // Suppress: this is our own loop task, joined on stop.
+ await _eventLoop.ConfigureAwait(false);
+#pragma warning restore VSTHRD003
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown.
+ }
+ }
+
+ await supervisor.StopAllAsync().ConfigureAwait(false);
+ }
+
+ private async Task RunAsync(CancellationToken cancellationToken)
+ {
+ // Subscription is registered when this loop first awaits the bus, after StartAllAsync completes.
+ // The in-process bus does not replay, so events published before this point are not delivered. Safe:
+ // the server-registered event is only raised by the FCM pairing flow at runtime, long after startup.
+ try
+ {
+ await supervisor.StartAllAsync(cancellationToken).ConfigureAwait(false);
+
+ await foreach (var registered in eventBus.SubscribeAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch is intentional: a faulting consumer must not crash the host.
+ catch (Exception ex)
+ {
+ LogLoopFaulted(logger, ex);
+ }
+#pragma warning restore CA1031
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Connection hosted-service loop faulted.")]
+ private static partial void LogLoopFaulted(ILogger logger, Exception exception);
+}
diff --git a/src/RustPlusBot.Features.Connections/Listening/HeartbeatResult.cs b/src/RustPlusBot.Features.Connections/Listening/HeartbeatResult.cs
new file mode 100644
index 00000000..c0eef261
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/HeartbeatResult.cs
@@ -0,0 +1,32 @@
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// Classification of a heartbeat probe.
+internal enum HeartbeatKind
+{
+ /// Server answered; is valid.
+ Ok = 0,
+
+ /// Server did not answer in time.
+ Unreachable = 1,
+
+ /// Server rejected the request as unauthorized (token died mid-session).
+ AuthRejected = 2,
+}
+
+/// The outcome of a heartbeat probe.
+/// The classification.
+/// Players online (only meaningful when ).
+/// is a method because it carries a player count;
+/// and are constants.
+internal readonly record struct HeartbeatResult(HeartbeatKind Kind, int PlayerCount)
+{
+ /// An unreachable heartbeat.
+ public static HeartbeatResult Unreachable { get; } = new(HeartbeatKind.Unreachable, 0);
+
+ /// An auth-rejected heartbeat.
+ public static HeartbeatResult AuthRejected { get; } = new(HeartbeatKind.AuthRejected, 0);
+
+ /// A healthy heartbeat carrying the player count.
+ /// The number of players currently online.
+ public static HeartbeatResult Ok(int playerCount) => new(HeartbeatKind.Ok, playerCount);
+}
diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
new file mode 100644
index 00000000..cbbc5a97
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
@@ -0,0 +1,17 @@
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// One live Rust+ socket to a single server, driven by one player credential.
+internal interface IRustServerConnection : IAsyncDisposable
+{
+ /// Connects within .
+ /// How long to wait for the connection.
+ /// A cancellation token.
+ /// The connect outcome.
+ Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken);
+
+ /// Sends a lightweight info request as a heartbeat (and auth probe), within .
+ /// How long to wait for the response.
+ /// A cancellation token.
+ /// The heartbeat result.
+ Task GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/IRustSocketSource.cs
new file mode 100644
index 00000000..d75f38a7
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/IRustSocketSource.cs
@@ -0,0 +1,13 @@
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// Creates s (the real RustPlusApi adapter in production, a fake in tests).
+internal interface IRustSocketSource
+{
+ /// Creates a connection to : as the given player.
+ /// Server host.
+ /// Server Rust+ port.
+ /// The player's Steam64 id.
+ /// The player's Rust+ token (numeric, as a string).
+ /// A new connection.
+ IRustServerConnection Create(string ip, int port, ulong steamId, string playerToken);
+}
diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
new file mode 100644
index 00000000..81090e54
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
@@ -0,0 +1,158 @@
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+using RustPlusApi;
+
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// Real backed by RustPlusApi. Untested integration shim.
+/// The logger.
+internal sealed partial class RustPlusSocketSource(ILogger logger) : IRustSocketSource
+{
+ ///
+ public IRustServerConnection Create(string ip, int port, ulong steamId, string playerToken)
+ {
+ if (!int.TryParse(playerToken, CultureInfo.InvariantCulture, out var token))
+ {
+ LogInvalidToken(logger);
+ return new RejectedConnection();
+ }
+
+ return new RustPlusServerConnection(ip, port, steamId, token, logger);
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning,
+ Message = "Player token is not a valid numeric token; treating the credential as rejected.")]
+ private static partial void LogInvalidToken(ILogger logger);
+
+ /// Returned when the player token is unusable; reports the credential as rejected and does nothing else.
+ private sealed class RejectedConnection : IRustServerConnection
+ {
+ public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
+ Task.FromResult(SocketConnectOutcome.AuthRejected);
+
+ public Task GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
+ Task.FromResult(HeartbeatResult.AuthRejected);
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+
+ ///
+ /// One live Rust+ connection backed by .
+ /// API notes (2.0.0-beta.1): constructor takes instead of
+ /// individual parameters; is ;
+ /// ConnectAsync accepts a and throws on failure;
+ /// GetInfoAsync returns Response<ServerInfo?> with ServerInfo.PlayerCount
+ /// typed as uint?.
+ ///
+ private sealed partial class RustPlusServerConnection : IRustServerConnection
+ {
+ private readonly ILogger _logger;
+ private readonly RustPlus _rustPlus;
+
+ public RustPlusServerConnection(string ip, int port, ulong steamId, int playerToken, ILogger logger)
+ {
+ _logger = logger;
+ // CONFIRMED: RustPlusConnection(string Server, int Port, ulong PlayerId, int PlayerToken, bool UseFacepunchProxy).
+ var connection = new RustPlusConnection(ip, port, steamId, playerToken, UseFacepunchProxy: false);
+ _rustPlus = new RustPlus(connection);
+ }
+
+ public async Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(timeout);
+ var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ void OnConnected(object? sender, EventArgs e) => connected.TrySetResult();
+
+ // CONFIRMED: event EventHandler? Connected — fires synchronously inside ConnectAsync before it returns.
+ _rustPlus.Connected += OnConnected;
+ try
+ {
+ // CONFIRMED: ConnectAsync(CancellationToken) accepts a token; throws WebSocketException on
+ // failure (v1.x swallowed into ErrorOccurred; v2.x throws). The Connected event fires
+ // synchronously inside ConnectAsync on success, so connected.Task may already be completed.
+ await _rustPlus.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
+ await connected.Task.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
+ return SocketConnectOutcome.Connected;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return SocketConnectOutcome.Unreachable;
+ }
+#pragma warning disable CA1031 // Broad catch: any non-cancellation connect failure maps to Unreachable; auth is probed by the first heartbeat.
+ catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
+#pragma warning restore CA1031
+ {
+ LogConnectFailed(_logger, ex);
+ return SocketConnectOutcome.Unreachable;
+ }
+ finally
+ {
+ _rustPlus.Connected -= OnConnected;
+ }
+ }
+
+ public async Task GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(timeout);
+ try
+ {
+ // CONFIRMED: GetInfoAsync(CancellationToken) returns Task> in 2.0.0-beta.1.
+ // Response.IsSuccess and Response.Data are the accessors.
+ // CONFIRMED: ServerInfo.PlayerCount is uint? (not int, not .Players).
+ // .WaitAsync guarantees we return within the timeout even if GetInfoAsync doesn't internally honor the token (unverified beta).
+ var response = await _rustPlus.GetInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
+ .ConfigureAwait(false);
+ if (!response.IsSuccess)
+ {
+ // VERIFY: detect the specific auth/token-rejected error shape from response or error message
+ // and return HeartbeatResult.AuthRejected once confirmed. Conservative default: Unreachable.
+ return HeartbeatResult.Unreachable;
+ }
+
+ var playerCount = (int)(response.Data?.PlayerCount ?? 0u);
+ return HeartbeatResult.Ok(playerCount);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return HeartbeatResult.Unreachable;
+ }
+#pragma warning disable CA1031 // Broad catch: classify an info failure. VERIFY: detect the auth/token-rejected error and return AuthRejected.
+ catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
+#pragma warning restore CA1031
+ {
+ LogHeartbeatFailed(_logger, ex);
+ // Conservative default: treat as unreachable. Map the specific "not authorized / invalid token"
+ // RustPlusApi error to HeartbeatResult.AuthRejected once its shape is confirmed.
+ return HeartbeatResult.Unreachable;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ // CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1.
+ // DisposeAsync() is preferred over DisconnectAsync() for teardown.
+ await _rustPlus.DisposeAsync().ConfigureAwait(false);
+ }
+#pragma warning disable CA1031 // Broad catch: teardown failures must not throw from disposal.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogDisposeFailed(_logger, ex);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Rust+ socket connect failed.")]
+ private static partial void LogConnectFailed(ILogger logger, Exception ex);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Rust+ heartbeat (GetInfo) failed.")]
+ private static partial void LogHeartbeatFailed(ILogger logger, Exception ex);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Rust+ socket teardown failed.")]
+ private static partial void LogDisposeFailed(ILogger logger, Exception ex);
+ }
+}
diff --git a/src/RustPlusBot.Features.Connections/Listening/SocketConnectOutcome.cs b/src/RustPlusBot.Features.Connections/Listening/SocketConnectOutcome.cs
new file mode 100644
index 00000000..8bcdebe4
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/SocketConnectOutcome.cs
@@ -0,0 +1,14 @@
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// Result of an initial socket connect attempt.
+internal enum SocketConnectOutcome
+{
+ /// Socket connected.
+ Connected = 0,
+
+ /// The server rejected the player token (credential problem).
+ AuthRejected = 1,
+
+ /// The server could not be reached (network/down).
+ Unreachable = 2,
+}
diff --git a/src/RustPlusBot.Features.Connections/Modules/ConnectionComponentModule.cs b/src/RustPlusBot.Features.Connections/Modules/ConnectionComponentModule.cs
new file mode 100644
index 00000000..64903e65
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Modules/ConnectionComponentModule.cs
@@ -0,0 +1,54 @@
+using Discord;
+using Discord.Interactions;
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Features.Connections.Supervisor;
+using RustPlusBot.Features.Workspace;
+using RustPlusBot.Persistence.Connections;
+
+namespace RustPlusBot.Features.Connections.Modules;
+
+/// Handles the #info "switch active player" select (ManageGuild).
+/// Creates a short-lived DI scope per interaction.
+public sealed class ConnectionComponentModule(IServiceScopeFactory scopeFactory)
+ : InteractionModuleBase
+{
+ /// Promotes the chosen pooled credential to active and restarts the socket on it.
+ /// The server id captured from the custom id.
+ /// The selected credential id (expects exactly one).
+ // Custom id: WorkspaceComponentIds.ServerInfoSwapPrefix + "*" = "workspace:info:swap:*"
+ [ComponentInteraction(WorkspaceComponentIds.ServerInfoSwapPrefix + "*")]
+ [RequireUserPermission(GuildPermission.ManageGuild)]
+ public async Task SwapAsync(string serverIdRaw, string[] selectedValues)
+ {
+ ArgumentNullException.ThrowIfNull(selectedValues);
+ if (Context.Guild is null)
+ {
+ await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ if (!Guid.TryParse(serverIdRaw, out var serverId)
+ || selectedValues.Length != 1
+ || !Guid.TryParse(selectedValues[0], out var credentialId))
+ {
+ await RespondAsync("That selection wasn't valid.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await DeferAsync(ephemeral: true).ConfigureAwait(false);
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ if (!await store.PromoteAsync(Context.Guild.Id, serverId, credentialId).ConfigureAwait(false))
+ {
+ await FollowupAsync("That player isn't available to activate.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ var supervisor = scope.ServiceProvider.GetRequiredService();
+ await supervisor.EnsureConnectionAsync(Context.Guild.Id, serverId).ConfigureAwait(false);
+ await FollowupAsync("Switching active player…", ephemeral: true).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj b/src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj
new file mode 100644
index 00000000..8df79e3f
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
new file mode 100644
index 00000000..57edffb9
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
@@ -0,0 +1,402 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Discord.Notifications;
+using RustPlusBot.Domain.Connections;
+using RustPlusBot.Domain.Credentials;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Persistence.Connections;
+using RustPlusBot.Persistence.Servers;
+
+namespace RustPlusBot.Features.Connections.Supervisor;
+
+/// Default : one connect->heartbeat->failover loop per (guild, server).
+/// Creates sockets (RustPlusApi in production, a fake in tests).
+/// Opens scopes for the scoped stores.
+/// DMs an owner when their credential is rejected.
+/// Unprotects stored tokens before connecting.
+/// Publishes ConnectionStatusChangedEvent on state changes.
+/// Timeouts/backoff/heartbeat settings.
+/// The logger.
+internal sealed partial class ConnectionSupervisor(
+ IRustSocketSource source,
+ IServiceScopeFactory scopeFactory,
+ IUserDmSender dmSender,
+ ICredentialProtector protector,
+ IEventBus eventBus,
+ IOptions options,
+ ILogger logger) : IConnectionSupervisor, IAsyncDisposable
+{
+ private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Handle> _connections = new();
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private readonly ConnectionOptions _options = options.Value;
+ private readonly CancellationTokenSource _shutdown = new();
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await StopAllAsync().ConfigureAwait(false);
+ _shutdown.Dispose();
+ _gate.Dispose();
+ }
+
+ ///
+ public async Task StartAllAsync(CancellationToken cancellationToken = default)
+ {
+ IReadOnlyList<(ulong GuildId, Guid ServerId)> servers;
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ servers = await store.ListConnectableServersAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ foreach (var (guildId, serverId) in servers)
+ {
+ await EnsureConnectionAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ public async Task EnsureConnectionAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default)
+ {
+ var key = (guildId, serverId);
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await StopConnectionAsync(key).ConfigureAwait(false);
+ if (_shutdown.IsCancellationRequested)
+ {
+ return;
+ }
+
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token);
+ _connections[key] = new Handle(cts, Task.Run(() => RunAsync(key, cts.Token), CancellationToken.None));
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ ///
+ public async Task StopAsync(ulong guildId, Guid serverId)
+ {
+ await _gate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ await StopConnectionAsync((guildId, serverId)).ConfigureAwait(false);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ ///
+ public async Task StopAllAsync()
+ {
+ await _shutdown.CancelAsync().ConfigureAwait(false);
+ await _gate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ foreach (var key in _connections.Keys.ToList())
+ {
+ await StopConnectionAsync(key).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")]
+ private static partial void LogLoopFaulted(ILogger logger, Exception exception, Guid serverId);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Stored token for credential {CredentialId} is unreadable.")]
+ private static partial void LogUnreadableToken(ILogger logger, Exception exception, Guid credentialId);
+
+ private async Task StopConnectionAsync((ulong Guild, Guid Server) key)
+ {
+ if (!_connections.TryRemove(key, out var handle))
+ {
+ return;
+ }
+
+ await handle.StopAsync().ConfigureAwait(false);
+ await handle.DisposeAsync().ConfigureAwait(false);
+ }
+
+ private async Task RunAsync((ulong Guild, Guid Server) key, CancellationToken ct)
+ {
+ var delay = _options.InitialRetryDelay;
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ var prepared = await PrepareAsync(key, ct).ConfigureAwait(false);
+ if (prepared is null)
+ {
+ await PublishStatusAsync(key, ConnectionStatus.NoCredentials, null, null, ct).ConfigureAwait(false);
+ return;
+ }
+
+ var p = prepared.Value;
+ await PublishStatusAsync(key, ConnectionStatus.Connecting, null, p.CredentialId, ct)
+ .ConfigureAwait(false);
+
+ var connection = source.Create(p.Ip, p.Port, p.SteamId, p.PlayerToken);
+ SocketConnectOutcome outcome;
+ try
+ {
+ outcome = await connection.ConnectAsync(_options.ConnectTimeout, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ await connection.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+
+ if (outcome == SocketConnectOutcome.AuthRejected)
+ {
+ await connection.DisposeAsync().ConfigureAwait(false);
+ // No backoff here: failover should be prompt. The loop is bounded — each rejection permanently
+ // marks one credential Invalid (never re-selected), so an all-reject pool converges to NoCredentials.
+ await FailoverAsync(p.CredentialId, p.OwnerUserId, p.ServerName, ct).ConfigureAwait(false);
+ delay = _options.InitialRetryDelay;
+ continue;
+ }
+
+ if (outcome == SocketConnectOutcome.Unreachable)
+ {
+ await connection.DisposeAsync().ConfigureAwait(false);
+ await PublishStatusAsync(key, ConnectionStatus.Unreachable, null, p.CredentialId, ct)
+ .ConfigureAwait(false);
+ await Task.Delay(delay, ct).ConfigureAwait(false);
+ delay = NextDelay(delay);
+ continue;
+ }
+
+ // Connected: run the heartbeat loop until it signals a reason to reconnect.
+ delay = _options.InitialRetryDelay;
+ ReconnectReason reason;
+ try
+ {
+ reason = await RunConnectedAsync(key, connection, p.CredentialId, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ await connection.DisposeAsync().ConfigureAwait(false);
+ }
+
+ if (reason == ReconnectReason.AuthRejected)
+ {
+ // No backoff: failover is prompt; pool exhaustion converges to NoCredentials.
+ await FailoverAsync(p.CredentialId, p.OwnerUserId, p.ServerName, ct).ConfigureAwait(false);
+ }
+ else if (reason == ReconnectReason.Unreachable)
+ {
+ await PublishStatusAsync(key, ConnectionStatus.Unreachable, null, p.CredentialId, ct)
+ .ConfigureAwait(false);
+ await Task.Delay(delay, ct).ConfigureAwait(false);
+ delay = NextDelay(delay);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Stopping.
+ }
+#pragma warning disable CA1031 // Broad catch is intentional: a faulting loop must not crash the host or other servers.
+ catch (Exception ex)
+ {
+ LogLoopFaulted(logger, ex, key.Server);
+ }
+#pragma warning restore CA1031
+ }
+
+ private async Task RunConnectedAsync(
+ (ulong Guild, Guid Server) key,
+ IRustServerConnection connection,
+ Guid credentialId,
+ CancellationToken ct)
+ {
+ var first = await connection.GetInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
+ if (first.Kind == HeartbeatKind.AuthRejected)
+ {
+ return ReconnectReason.AuthRejected;
+ }
+
+ if (first.Kind == HeartbeatKind.Unreachable)
+ {
+ return ReconnectReason.Unreachable;
+ }
+
+ await PublishStatusAsync(key, ConnectionStatus.Connected, first.PlayerCount, credentialId, ct)
+ .ConfigureAwait(false);
+
+ while (!ct.IsCancellationRequested)
+ {
+ await Task.Delay(_options.HeartbeatInterval, ct).ConfigureAwait(false);
+ var beat = await connection.GetInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
+ switch (beat.Kind)
+ {
+ case HeartbeatKind.Ok:
+ await PublishStatusAsync(key, ConnectionStatus.Connected, beat.PlayerCount, credentialId, ct)
+ .ConfigureAwait(false);
+ break;
+ case HeartbeatKind.AuthRejected:
+ return ReconnectReason.AuthRejected;
+ default:
+ return ReconnectReason.Unreachable;
+ }
+ }
+
+ return ReconnectReason.Stopped;
+ }
+
+ private async Task PrepareAsync((ulong Guild, Guid Server) key, CancellationToken ct)
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ var servers = scope.ServiceProvider.GetRequiredService();
+
+ var server = await servers.GetAsync(key.Guild, key.Server, ct).ConfigureAwait(false);
+ if (server is null)
+ {
+ return null;
+ }
+
+ while (!ct.IsCancellationRequested)
+ {
+ var active = await store.GetActiveCredentialAsync(key.Guild, key.Server, ct).ConfigureAwait(false);
+ if (active is null)
+ {
+ var pool = await store.ListPoolAsync(key.Guild, key.Server, ct).ConfigureAwait(false);
+ var next = pool.FirstOrDefault(c => c.Status == CredentialStatus.Standby);
+ if (next is null)
+ {
+ return null;
+ }
+
+ await store.PromoteAsync(key.Guild, key.Server, next.Id, ct).ConfigureAwait(false);
+ active = next;
+ }
+
+ string token;
+ try
+ {
+ token = protector.Unprotect(active.ProtectedPlayerToken);
+ }
+ catch (CryptographicException ex)
+ {
+ LogUnreadableToken(logger, ex, active.Id);
+ await store.MarkInvalidAsync(active.Id, ct).ConfigureAwait(false);
+ await dmSender.SendAsync(
+ active.OwnerUserId,
+ $"Your Rust+ credential for **{server.Name}** could not be read — reconnect in #setup.",
+ ct)
+ .ConfigureAwait(false);
+ continue;
+ }
+
+ return new Prepared(server.Ip, server.Port, server.Name, active.Id, active.OwnerUserId, active.SteamId,
+ token);
+ }
+
+ return null;
+ }
+ }
+
+ private async Task FailoverAsync(Guid credentialId, ulong ownerUserId, string serverName, CancellationToken ct)
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ await store.MarkInvalidAsync(credentialId, ct).ConfigureAwait(false);
+ }
+
+ await dmSender.SendAsync(
+ ownerUserId,
+ $"Your Rust+ credential for **{serverName}** was rejected — reconnect in #setup to keep it in the pool.",
+ ct)
+ .ConfigureAwait(false);
+ }
+
+ private async Task PublishStatusAsync(
+ (ulong Guild, Guid Server) key,
+ ConnectionStatus status,
+ int? playerCount,
+ Guid? activeCredentialId,
+ CancellationToken ct)
+ {
+ bool changed;
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ changed = await store
+ .UpsertStatusAsync(key.Guild, key.Server, status, playerCount, activeCredentialId, ct)
+ .ConfigureAwait(false);
+ }
+
+ if (changed)
+ {
+ await eventBus.PublishAsync(new ConnectionStatusChangedEvent(key.Guild, key.Server), ct)
+ .ConfigureAwait(false);
+ }
+ }
+
+ private TimeSpan NextDelay(TimeSpan delay) =>
+ delay < _options.MaxRetryDelay
+ ? TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, _options.MaxRetryDelay.Ticks))
+ : _options.MaxRetryDelay;
+
+ private enum ReconnectReason
+ {
+ Stopped = 0,
+ Unreachable = 1,
+ AuthRejected = 2,
+ }
+
+ private readonly record struct Prepared(
+ string Ip,
+ int Port,
+ string ServerName,
+ Guid CredentialId,
+ ulong OwnerUserId,
+ ulong SteamId,
+ string PlayerToken);
+
+ private sealed class Handle(CancellationTokenSource cts, Task runTask) : IAsyncDisposable
+ {
+ public ValueTask DisposeAsync()
+ {
+ cts.Dispose();
+ return ValueTask.CompletedTask;
+ }
+
+ public async Task StopAsync()
+ {
+ await cts.CancelAsync().ConfigureAwait(false);
+ try
+ {
+#pragma warning disable VSTHRD003 // Suppress: task is owned by this Handle and explicitly joined on stop.
+ await runTask.ConfigureAwait(false);
+#pragma warning restore VSTHRD003
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on stop.
+ }
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Connections/Supervisor/IConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/IConnectionSupervisor.cs
new file mode 100644
index 00000000..6457f328
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Supervisor/IConnectionSupervisor.cs
@@ -0,0 +1,23 @@
+namespace RustPlusBot.Features.Connections.Supervisor;
+
+/// Owns the live Rust+ sockets — one per (guild, server).
+internal interface IConnectionSupervisor
+{
+ /// Starts a connection for every server that has a non-Invalid credential (called once at startup).
+ /// A cancellation token.
+ Task StartAllAsync(CancellationToken cancellationToken = default);
+
+ /// Starts (or restarts) the connection for one server (after a swap or on server registration).
+ /// The owning guild snowflake.
+ /// The server.
+ /// A cancellation token.
+ Task EnsureConnectionAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
+
+ /// Stops the connection for one server, if running.
+ /// The owning guild snowflake.
+ /// The server.
+ Task StopAsync(ulong guildId, Guid serverId);
+
+ /// Cancels and disposes every connection (called on shutdown).
+ Task StopAllAsync();
+}
diff --git a/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs b/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs
index ba1713dc..459901a9 100644
--- a/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs
+++ b/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs
@@ -1,45 +1,18 @@
-using Discord;
-using Discord.WebSocket;
-using Microsoft.Extensions.Logging;
+using RustPlusBot.Discord.Notifications;
namespace RustPlusBot.Features.Pairing.Notifications;
-/// DMs the owner. A closed-DM failure is swallowed (logged): the Expired state still shows in #setup.
-/// The socket client.
-/// The logger.
-internal sealed partial class DiscordOwnerNotifier(DiscordSocketClient client, ILogger logger)
- : IOwnerNotifier
+/// DMs the owner when their FCM credentials expire, via the shared .
+/// Sends the DM (swallows a closed DM).
+internal sealed class DiscordOwnerNotifier(IUserDmSender dmSender) : IOwnerNotifier
{
///
- public async Task NotifyCredentialsExpiredAsync(
+ public Task NotifyCredentialsExpiredAsync(
ulong guildId,
ulong ownerUserId,
- CancellationToken cancellationToken = default)
- {
- try
- {
- var user = await client.GetUserAsync(ownerUserId).ConfigureAwait(false);
- if (user is null)
- {
- LogUserNotFound(logger, ownerUserId);
- return;
- }
-
- await user.SendMessageAsync(
- "Your Rust+ credentials were rejected. Reconnect your account in #setup to keep receiving pairings.")
- .ConfigureAwait(false);
- }
-#pragma warning disable CA1031 // Broad catch is intentional: a closed DM (or any send failure) must not break the supervisor.
- catch (Exception ex)
-#pragma warning restore CA1031
- {
- LogDmFailed(logger, ex, ownerUserId);
- }
- }
-
- [LoggerMessage(Level = LogLevel.Warning, Message = "Cannot DM owner {OwnerId}: user not found.")]
- private static partial void LogUserNotFound(ILogger logger, ulong ownerId);
-
- [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to DM owner {OwnerId} about expired credentials.")]
- private static partial void LogDmFailed(ILogger logger, Exception ex, ulong ownerId);
+ CancellationToken cancellationToken = default) =>
+ dmSender.SendAsync(
+ ownerUserId,
+ "Your Rust+ credentials were rejected. Reconnect your account in #setup to keep receiving pairings.",
+ cancellationToken);
}
diff --git a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs
index e81c2712..db62b4c8 100644
--- a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs
+++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs
@@ -20,7 +20,8 @@ internal sealed class WorkspaceHostedService(
ILogger logger) : IHostedService, IDisposable
{
private readonly CancellationTokenSource _cts = new();
- private Task? _eventLoop;
+ private Task? _connectionStatusLoop;
+ private Task? _serverRegisteredLoop;
private bool _startupDone;
///
@@ -31,7 +32,8 @@ public Task StartAsync(CancellationToken cancellationToken)
{
client.Ready += OnReadyAsync;
client.ChannelDestroyed += OnChannelDestroyedAsync;
- _eventLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None);
+ _serverRegisteredLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None);
+ _connectionStatusLoop = Task.Run(() => ConsumeConnectionStatusAsync(_cts.Token), CancellationToken.None);
return Task.CompletedTask;
}
@@ -41,12 +43,20 @@ public async Task StopAsync(CancellationToken cancellationToken)
client.Ready -= OnReadyAsync;
client.ChannelDestroyed -= OnChannelDestroyedAsync;
await _cts.CancelAsync().ConfigureAwait(false);
- if (_eventLoop is not null)
+ foreach (var loop in new[]
+ {
+ _serverRegisteredLoop, _connectionStatusLoop
+ })
{
+ if (loop is null)
+ {
+ continue;
+ }
+
try
{
-#pragma warning disable VSTHRD003 // Avoid awaiting or returning a Task representing work that was not started within your context
- await _eventLoop.ConfigureAwait(false);
+#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — these are our own loop tasks, joined on stop.
+ await loop.ConfigureAwait(false);
#pragma warning restore VSTHRD003
}
catch (OperationCanceledException)
@@ -98,6 +108,34 @@ private async Task OnChannelDestroyedAsync(SocketChannel channel)
}
}
+ private async Task ConsumeConnectionStatusAsync(CancellationToken cancellationToken)
+ {
+ // If this loop faults (broad catch), the consumer exits permanently and info channels stop
+ // updating until the host restarts. Acceptable: the reconciler is idempotent and a restart heals.
+ try
+ {
+ await foreach (var changed in eventBus.SubscribeAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var reconciler = scope.ServiceProvider.GetRequiredService();
+ await reconciler.ReconcileServerAsync(changed.GuildId, changed.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+ catch (Exception ex) // Broad catch is intentional: a faulting consumer must not crash the host.
+ {
+ logger.LogError(ex, "ConnectionStatusChanged consumer faulted.");
+ }
+ }
+
private async Task ConsumeServerRegisteredAsync(CancellationToken cancellationToken)
{
// Subscription is registered when this loop first calls SubscribeAsync; the in-process bus does
diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
index 8f3c0ea0..408c7630 100644
--- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
+++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
@@ -28,8 +28,16 @@ internal sealed class LocalizationCatalog
["settings.title"] = "Settings",
["settings.body"] = "Configure the bot for this server.",
["settings.language.label"] = "Language",
- ["server.info.title"] = "{0}",
["server.info.endpoint"] = "Endpoint: {0}:{1}",
+ ["server.info.status.label"] = "Status",
+ ["server.info.status.connecting"] = "Connecting…",
+ ["server.info.status.connected"] = "Connected",
+ ["server.info.status.unreachable"] = "Server unreachable",
+ ["server.info.status.nocredentials"] = "No working credentials",
+ ["server.info.player.label"] = "Active player",
+ ["server.info.players.label"] = "Players online",
+ ["server.info.swap.placeholder"] = "Switch active player",
+ ["server.info.none"] = "—",
},
["fr"] = new Dictionary(StringComparer.Ordinal)
{
@@ -49,8 +57,16 @@ internal sealed class LocalizationCatalog
["settings.title"] = "Parametres",
["settings.body"] = "Configurez le bot pour ce serveur.",
["settings.language.label"] = "Langue",
- ["server.info.title"] = "{0}",
["server.info.endpoint"] = "Adresse : {0}:{1}",
+ ["server.info.status.label"] = "État",
+ ["server.info.status.connecting"] = "Connexion…",
+ ["server.info.status.connected"] = "Connecté",
+ ["server.info.status.unreachable"] = "Serveur injoignable",
+ ["server.info.status.nocredentials"] = "Aucun identifiant valide",
+ ["server.info.player.label"] = "Joueur actif",
+ ["server.info.players.label"] = "Joueurs en ligne",
+ ["server.info.swap.placeholder"] = "Changer le joueur actif",
+ ["server.info.none"] = "—",
},
},
};
diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs
index ef54731e..bde68d28 100644
--- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs
+++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs
@@ -1,16 +1,23 @@
using System.Globalization;
using Discord;
+using RustPlusBot.Domain.Connections;
+using RustPlusBot.Domain.Credentials;
using RustPlusBot.Features.Workspace.Gateway;
using RustPlusBot.Features.Workspace.Localization;
using RustPlusBot.Features.Workspace.Registry;
+using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Servers;
namespace RustPlusBot.Features.Workspace.Messages;
-/// Renders a server's #info identity embed (static in 1a; live status enriched in 1b).
+/// Renders a server's #info embed with live connection status and the ManageGuild swap select.
/// Server lookup.
+/// Live connection state + pool.
/// String resolution.
-internal sealed class ServerInfoMessageRenderer(IServerService servers, ILocalizer localizer) : IMessageRenderer
+internal sealed class ServerInfoMessageRenderer(
+ IServerService servers,
+ IConnectionStore connections,
+ ILocalizer localizer) : IMessageRenderer
{
///
public string MessageKey => WorkspaceMessageKeys.ServerInfo;
@@ -31,12 +38,70 @@ public async ValueTask RenderAsync(MessageRenderContext context,
return new MessagePayload(null, null, null);
}
+ var state = await connections.GetStateAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false);
+ var pool = await connections.ListPoolAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false);
+ var status = state?.Status ?? ConnectionStatus.NoCredentials;
+
+ var active = pool.FirstOrDefault(c => state?.ActiveCredentialId is Guid id && c.Id == id)
+ ?? pool.FirstOrDefault(c => c.Status == CredentialStatus.Active);
+ var none = localizer.Get("server.info.none", context.Culture);
var port = server.Port.ToString(CultureInfo.InvariantCulture);
+
var embed = new EmbedBuilder()
- .WithTitle(localizer.Get("server.info.title", context.Culture, server.Name))
+ .WithTitle($"{Glyph(status)} {server.Name}")
.WithDescription(localizer.Get("server.info.endpoint", context.Culture, server.Ip, port))
- .WithColor(Color.Green)
- .Build();
- return new MessagePayload(null, embed, null);
+ .WithColor(ColorFor(status))
+ .AddField(localizer.Get("server.info.status.label", context.Culture), StatusText(status, context.Culture))
+ .AddField(localizer.Get("server.info.player.label", context.Culture),
+ active is null ? none : active.SteamId.ToString(CultureInfo.InvariantCulture));
+
+ if (status == ConnectionStatus.Connected && state?.PlayerCount is int count)
+ {
+ embed.AddField(localizer.Get("server.info.players.label", context.Culture),
+ count.ToString(CultureInfo.InvariantCulture));
+ }
+
+ var eligible = pool
+ .Where(c => c.Status != CredentialStatus.Invalid)
+ .Take(SelectMenuBuilder.MaxOptionCount) // Discord hard-limits a select menu to 25 options.
+ .ToList();
+ MessageComponent? components = null;
+ if (eligible.Count > 0)
+ {
+ var select = new SelectMenuBuilder()
+ .WithCustomId($"{WorkspaceComponentIds.ServerInfoSwapPrefix}{serverId}")
+ .WithPlaceholder(localizer.Get("server.info.swap.placeholder", context.Culture));
+ foreach (var credential in eligible)
+ {
+ var label = credential.SteamId.ToString(CultureInfo.InvariantCulture);
+ select.AddOption(label, credential.Id.ToString(), isDefault: credential.Id == active?.Id);
+ }
+
+ components = new ComponentBuilder().WithSelectMenu(select).Build();
+ }
+
+ return new MessagePayload(null, embed.Build(), components);
}
+
+ private static string Glyph(ConnectionStatus status) => status switch
+ {
+ ConnectionStatus.Connected => "🟢",
+ ConnectionStatus.Connecting or ConnectionStatus.Unreachable => "🟡",
+ _ => "🔴",
+ };
+
+ private static Color ColorFor(ConnectionStatus status) => status switch
+ {
+ ConnectionStatus.Connected => Color.Green,
+ ConnectionStatus.Connecting or ConnectionStatus.Unreachable => Color.Gold,
+ _ => Color.Red,
+ };
+
+ private string StatusText(ConnectionStatus status, string culture) => status switch
+ {
+ ConnectionStatus.Connecting => localizer.Get("server.info.status.connecting", culture),
+ ConnectionStatus.Connected => localizer.Get("server.info.status.connected", culture),
+ ConnectionStatus.Unreachable => localizer.Get("server.info.status.unreachable", culture),
+ _ => localizer.Get("server.info.status.nocredentials", culture),
+ };
}
diff --git a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj
index 4287f1cf..136df242 100644
--- a/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj
+++ b/src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj
@@ -2,6 +2,7 @@
+
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs
index 5e4683a8..e86a8228 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs
@@ -8,4 +8,7 @@ public static class WorkspaceComponentIds
{
/// The #setup "Connect account" button; handled by the Pairing feature.
public const string ConnectAccount = "workspace:setup:connect";
+
+ /// Prefix for the #info "swap active player" select; the server id is appended. Handled by Connections.
+ public const string ServerInfoSwapPrefix = "workspace:info:swap:";
}
diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs
index a4307e4a..3f96477f 100644
--- a/src/RustPlusBot.Host/Program.cs
+++ b/src/RustPlusBot.Host/Program.cs
@@ -6,6 +6,7 @@
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Discord;
+using RustPlusBot.Features.Connections;
using RustPlusBot.Features.Pairing;
using RustPlusBot.Features.Workspace;
using RustPlusBot.Host.Credentials;
@@ -37,6 +38,18 @@
"Pairing:MaxRetryDelay must be at least InitialRetryDelay.")
.ValidateOnStart();
builder.Services.AddPairing();
+builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection("Connections"))
+ .Validate(static o => o.ConnectTimeout > TimeSpan.Zero, "Connections:ConnectTimeout must be positive.")
+ .Validate(static o => o.InitialRetryDelay > TimeSpan.Zero, "Connections:InitialRetryDelay must be positive.")
+ .Validate(static o => o.MaxRetryDelay >= o.InitialRetryDelay,
+ "Connections:MaxRetryDelay must be at least InitialRetryDelay.")
+ .Validate(static o => o.HeartbeatInterval > TimeSpan.Zero, "Connections:HeartbeatInterval must be positive.")
+ .Validate(static o => o.HeartbeatTimeout > TimeSpan.Zero, "Connections:HeartbeatTimeout must be positive.")
+ .Validate(static o => o.HeartbeatTimeout < o.HeartbeatInterval,
+ "Connections:HeartbeatTimeout must be less than HeartbeatInterval.")
+ .ValidateOnStart();
+builder.Services.AddConnections();
var host = builder.Build();
diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
index 64e36675..c8b36719 100644
--- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj
+++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
@@ -20,5 +20,6 @@
+
diff --git a/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs b/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs
new file mode 100644
index 00000000..3e426492
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs
@@ -0,0 +1,146 @@
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Connections;
+using RustPlusBot.Domain.Credentials;
+
+namespace RustPlusBot.Persistence.Connections;
+
+/// EF-backed .
+/// The bot database context.
+/// Supplies the update timestamp.
+public sealed class ConnectionStore(BotDbContext context, IClock clock) : IConnectionStore
+{
+ ///
+ public Task GetStateAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken = default) =>
+ context.ConnectionStates
+ .SingleOrDefaultAsync(s => s.GuildId == guildId && s.RustServerId == serverId, cancellationToken);
+
+ ///
+ public async Task UpsertStatusAsync(
+ ulong guildId,
+ Guid serverId,
+ ConnectionStatus status,
+ int? playerCount,
+ Guid? activeCredentialId,
+ CancellationToken cancellationToken = default)
+ {
+ var existing = await context.ConnectionStates
+ .SingleOrDefaultAsync(s => s.GuildId == guildId && s.RustServerId == serverId, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (existing is null)
+ {
+ context.ConnectionStates.Add(new ConnectionState
+ {
+ RustServerId = serverId,
+ GuildId = guildId,
+ Status = status,
+ PlayerCount = playerCount,
+ ActiveCredentialId = activeCredentialId,
+ UpdatedAt = clock.UtcNow,
+ });
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ if (existing.Status == status
+ && existing.PlayerCount == playerCount
+ && existing.ActiveCredentialId == activeCredentialId)
+ {
+ return false;
+ }
+
+ existing.Status = status;
+ existing.PlayerCount = playerCount;
+ existing.ActiveCredentialId = activeCredentialId;
+ existing.UpdatedAt = clock.UtcNow;
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ ///
+ public Task GetActiveCredentialAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken = default) =>
+ context.PlayerCredentials.SingleOrDefaultAsync(
+ c => c.GuildId == guildId && c.RustServerId == serverId && c.Status == CredentialStatus.Active,
+ cancellationToken);
+
+ ///
+ public async Task> ListPoolAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken = default) =>
+ await context.PlayerCredentials
+ .Where(c => c.GuildId == guildId && c.RustServerId == serverId)
+ .OrderBy(c => c.Id)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ ///
+ public async Task PromoteAsync(
+ ulong guildId,
+ Guid serverId,
+ Guid credentialId,
+ CancellationToken cancellationToken = default)
+ {
+ var pool = await context.PlayerCredentials
+ .Where(c => c.GuildId == guildId && c.RustServerId == serverId)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var target = pool.SingleOrDefault(c => c.Id == credentialId);
+ if (target is null || target.Status == CredentialStatus.Invalid)
+ {
+ return false;
+ }
+
+ foreach (var credential in pool)
+ {
+ if (credential.Status == CredentialStatus.Active && credential.Id != credentialId)
+ {
+ credential.Status = CredentialStatus.Standby;
+ }
+ }
+
+ target.Status = CredentialStatus.Active;
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ ///
+ public async Task MarkInvalidAsync(Guid credentialId, CancellationToken cancellationToken = default)
+ {
+ var credential = await context.PlayerCredentials
+ .SingleOrDefaultAsync(c => c.Id == credentialId, cancellationToken)
+ .ConfigureAwait(false);
+ if (credential is null)
+ {
+ return;
+ }
+
+ credential.Status = CredentialStatus.Invalid;
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task> ListConnectableServersAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var rows = await context.PlayerCredentials
+ .Where(c => c.Status != CredentialStatus.Invalid)
+ .Select(c => new
+ {
+ c.GuildId, c.RustServerId
+ })
+ .Distinct()
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ return rows.ConvertAll(r => (r.GuildId, r.RustServerId));
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Connections/IConnectionStore.cs b/src/RustPlusBot.Persistence/Connections/IConnectionStore.cs
new file mode 100644
index 00000000..b7cb3321
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Connections/IConnectionStore.cs
@@ -0,0 +1,84 @@
+using RustPlusBot.Domain.Connections;
+using RustPlusBot.Domain.Credentials;
+
+namespace RustPlusBot.Persistence.Connections;
+
+///
+/// Persists per-server live-connection state and the operations the connection supervisor and the
+/// #info renderer need. Lives in the persistence layer (returns Domain types).
+///
+public interface IConnectionStore
+{
+ /// Gets the connection state for a server, or null.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// A cancellation token.
+ /// The connection state, or null.
+ Task GetStateAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Upserts the connection state. Returns true only when the persisted (status, player count, active
+ /// credential) actually changed, so callers can skip a redundant #info refresh.
+ ///
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The current connection status.
+ /// The current player count, or null if unknown.
+ /// The active credential id, or null if none.
+ /// A cancellation token.
+ /// True if the persisted state changed; false if it was identical.
+ Task UpsertStatusAsync(
+ ulong guildId,
+ Guid serverId,
+ ConnectionStatus status,
+ int? playerCount,
+ Guid? activeCredentialId,
+ CancellationToken cancellationToken = default);
+
+ /// Gets the server's Active player credential (with protected token), or null.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// A cancellation token.
+ /// The active player credential, or null.
+ Task GetActiveCredentialAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken = default);
+
+ /// Lists the server's full credential pool, ordered by id.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// A cancellation token.
+ /// The credential pool.
+ Task> ListPoolAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Makes the server's Active credential and demotes the prior
+ /// active to Standby. Returns false if the credential is not an eligible (non-Invalid) pool member.
+ ///
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The credential to promote to active.
+ /// A cancellation token.
+ /// True if the credential was promoted; false if ineligible or not found.
+ Task PromoteAsync(
+ ulong guildId,
+ Guid serverId,
+ Guid credentialId,
+ CancellationToken cancellationToken = default);
+
+ /// Marks a credential Invalid (failover on auth-reject).
+ /// The credential to mark invalid.
+ /// A cancellation token.
+ /// A task that completes when the status has been persisted.
+ Task MarkInvalidAsync(Guid credentialId, CancellationToken cancellationToken = default);
+
+ /// Lists every (GuildId, ServerId) that has a non-Invalid credential (startup enumeration).
+ /// A cancellation token.
+ /// The connectable (GuildId, ServerId) pairs.
+ Task> ListConnectableServersAsync(
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260615134626_LiveConnections.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260615134626_LiveConnections.Designer.cs
new file mode 100644
index 00000000..496e8ca3
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260615134626_LiveConnections.Designer.cs
@@ -0,0 +1,466 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using RustPlusBot.Persistence;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ [DbContext(typeof(BotDbContext))]
+ [Migration("20260615134626_LiveConnections")]
+ partial class LiveConnections
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
+
+ modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParentId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("ParentId");
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Guilds");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("JoinedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Nickname")
+ .HasColumnType("TEXT");
+
+ b.HasKey("GuildId", "UserId");
+
+ b.ToTable("Members");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("Color")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Permissions")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.ToTable("Roles");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalName")
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.Property("RustServerId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ActiveCredentialId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayerCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("RustServerId");
+
+ b.ToTable("ConnectionStates");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedFcmCredentials")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("FcmRegistrations");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedPlayerToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("SteamId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("PlayerCredentials");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "RustServerId");
+
+ b.ToTable("PairedEntities");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EventKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "RustServerId");
+
+ b.ToTable("EventSubscriptions");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Culture")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.HasKey("GuildId");
+
+ b.ToTable("GuildSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Ip")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Port")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("GuildId", "Ip", "Port")
+ .IsUnique();
+
+ b.ToTable("RustServers");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordCategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId")
+ .IsUnique();
+
+ b.ToTable("ProvisionedCategories");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "ChannelKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedChannels");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("DiscordMessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "MessageKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedMessages");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b =>
+ {
+ b.HasOne("Persistord.Core.Entities.ChannelEntity", null)
+ .WithMany()
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260615134626_LiveConnections.cs b/src/RustPlusBot.Persistence/Migrations/20260615134626_LiveConnections.cs
new file mode 100644
index 00000000..4188aef1
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260615134626_LiveConnections.cs
@@ -0,0 +1,38 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ ///
+ public partial class LiveConnections : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.RenameColumn(
+ name: "IsHealthy",
+ table: "ConnectionStates",
+ newName: "Status");
+
+ migrationBuilder.AddColumn(
+ name: "PlayerCount",
+ table: "ConnectionStates",
+ type: "INTEGER",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "PlayerCount",
+ table: "ConnectionStates");
+
+ migrationBuilder.RenameColumn(
+ name: "Status",
+ table: "ConnectionStates",
+ newName: "IsHealthy");
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
index 4863537a..0bfd8c27 100644
--- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
+++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
@@ -134,7 +134,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property("GuildId")
.HasColumnType("INTEGER");
- b.Property("IsHealthy")
+ b.Property("PlayerCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
.HasColumnType("INTEGER");
b.Property("UpdatedAt")
diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
index efabc3ec..7b944672 100644
--- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Credentials;
using RustPlusBot.Persistence.Servers;
using RustPlusBot.Persistence.Workspace;
@@ -31,6 +32,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs
new file mode 100644
index 00000000..afe26086
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs
@@ -0,0 +1,38 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Hosting;
+using RustPlusBot.Features.Connections.Supervisor;
+
+namespace RustPlusBot.Features.Connections.Tests;
+
+public sealed class ConnectionHostedServiceTests
+{
+ [Fact]
+ public async Task Startup_CallsStartAll_And_ServerRegistered_EnsuresAConnection()
+ {
+ var supervisor = Substitute.For();
+ var bus = new InMemoryEventBus();
+ var service = new ConnectionHostedService(supervisor, bus, NullLogger.Instance);
+
+ await service.StartAsync(default);
+
+ var serverId = Guid.NewGuid();
+ // The consumer subscribes on a background loop after StartAllAsync; the bus does not replay,
+ // so re-publish until the consumer has processed at least one delivery (or the deadline hits).
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
+ while (DateTimeOffset.UtcNow < deadline
+ && !supervisor.ReceivedCalls().Any(c =>
+ c.GetMethodInfo().Name == nameof(IConnectionSupervisor.EnsureConnectionAsync)))
+ {
+ await bus.PublishAsync(new ServerRegisteredEvent(10UL, serverId));
+ await Task.Delay(20);
+ }
+
+ await supervisor.Received().StartAllAsync(Arg.Any());
+ await supervisor.Received().EnsureConnectionAsync(10UL, serverId, Arg.Any());
+
+ await service.StopAsync(default);
+ await supervisor.Received(1).StopAllAsync();
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs
new file mode 100644
index 00000000..280ab2df
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs
@@ -0,0 +1,40 @@
+using Discord.WebSocket;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Discord.Notifications;
+using RustPlusBot.Features.Connections;
+using RustPlusBot.Features.Connections.Supervisor;
+using RustPlusBot.Persistence;
+using RustPlusBot.Persistence.Connections;
+
+namespace RustPlusBot.Features.Connections.Tests;
+
+public sealed class ConnectionRegistrationTests
+{
+ [Fact]
+ public async Task Services_Resolve()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(new DiscordSocketClient());
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(Substitute.For());
+ services.AddSingleton(Substitute.For());
+ services.AddLogging();
+ services.AddBotPersistence("DataSource=:memory:");
+ services.AddOptions();
+ services.AddConnections();
+
+ await using var provider = services.BuildServiceProvider(new ServiceProviderOptions
+ {
+ ValidateScopes = true
+ });
+
+ Assert.NotNull(provider.GetRequiredService());
+ await using var scope = provider.CreateAsyncScope();
+ Assert.NotNull(scope.ServiceProvider.GetRequiredService());
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs
new file mode 100644
index 00000000..7eb1bc11
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs
@@ -0,0 +1,258 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Discord.Notifications;
+using RustPlusBot.Domain.Connections;
+using RustPlusBot.Domain.Credentials;
+using RustPlusBot.Domain.Servers;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Connections.Supervisor;
+using RustPlusBot.Features.Connections.Tests.Fakes;
+using RustPlusBot.Persistence;
+using RustPlusBot.Persistence.Connections;
+
+namespace RustPlusBot.Features.Connections.Tests;
+
+public sealed class ConnectionSupervisorTests
+{
+ private static Harness CreateHarness(FakeRustSocketSource source)
+ {
+ var protector = Substitute.For();
+ protector.Unprotect(Arg.Any()).Returns(c => c.Arg());
+
+ var clock = Substitute.For();
+ clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
+
+ var dm = Substitute.For();
+
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(clock);
+ services.AddSingleton(protector);
+ services.AddSingleton(dm);
+ services.AddSingleton();
+
+ // Each scope opens its OWN connection to a shared-cache in-memory database, so the background
+ // supervisor loop and the test's polling never run concurrent commands on a single SqliteConnection
+ // (which throws "active statements" misuse errors). One kept-open connection keeps the in-memory DB alive.
+ var connectionString = $"DataSource=connsup-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
+ var keepAlive = new SqliteConnection(connectionString);
+ keepAlive.Open();
+ using (var seed = new BotDbContext(
+ new DbContextOptionsBuilder().UseSqlite(connectionString).Options))
+ {
+ seed.Database.Migrate();
+ }
+
+ services.AddSingleton(keepAlive);
+ services.AddScoped(_ => new BotDbContext(
+ new DbContextOptionsBuilder().UseSqlite(connectionString).Options));
+ services.AddScoped();
+ services
+ .AddScoped();
+
+ services.AddSingleton(source);
+ services.AddSingleton(Options.Create(new ConnectionOptions
+ {
+ ConnectTimeout = TimeSpan.FromSeconds(1),
+ InitialRetryDelay = TimeSpan.FromMilliseconds(5),
+ MaxRetryDelay = TimeSpan.FromMilliseconds(20),
+ HeartbeatInterval = TimeSpan.FromMilliseconds(20),
+ HeartbeatTimeout = TimeSpan.FromMilliseconds(200),
+ }));
+ services.AddSingleton();
+
+ var provider = services.BuildServiceProvider();
+ return new Harness
+ {
+ Provider = provider, Dm = dm, Supervisor = provider.GetRequiredService()
+ };
+ }
+
+ private static async Task<(Guid ServerId, Guid CredA, Guid CredB)> SeedAsync(
+ ServiceProvider provider,
+ CredentialStatus bStatus = CredentialStatus.Standby)
+ {
+ using var scope = provider.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+ var server = new RustServer
+ {
+ GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ var a = new PlayerCredential
+ {
+ GuildId = 10UL,
+ RustServerId = server.Id,
+ OwnerUserId = 1UL,
+ SteamId = 100UL,
+ ProtectedPlayerToken = "111",
+ Status = CredentialStatus.Active,
+ };
+ var b = new PlayerCredential
+ {
+ GuildId = 10UL,
+ RustServerId = server.Id,
+ OwnerUserId = 2UL,
+ SteamId = 200UL,
+ ProtectedPlayerToken = "222",
+ Status = bStatus,
+ };
+ await context.PlayerCredentials.AddRangeAsync(a, b);
+ await context.SaveChangesAsync();
+ return (server.Id, a.Id, b.Id);
+ }
+
+ private static async Task WaitForStateAsync(
+ ServiceProvider provider,
+ Guid serverId,
+ Func predicate)
+ {
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(30);
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ using var scope = provider.CreateScope();
+ var store = scope.ServiceProvider.GetRequiredService();
+ var state = await store.GetStateAsync(10UL, serverId);
+ if (state is not null && predicate(state))
+ {
+ return state;
+ }
+
+ await Task.Delay(15);
+ }
+
+ return null;
+ }
+
+ private static async Task CredStatusAsync(ServiceProvider provider, Guid credId)
+ {
+ using var scope = provider.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+ return (await context.PlayerCredentials.SingleAsync(c => c.Id == credId)).Status;
+ }
+
+ [Fact]
+ public async Task Connect_Healthy_BecomesConnectedWithPlayerCount()
+ {
+ var source = new FakeRustSocketSource();
+ source.EnqueueConnect(SocketConnectOutcome.Connected);
+ source.EnqueueHeartbeat(HeartbeatResult.Ok(7));
+ await using var h = CreateHarness(source);
+ var (serverId, _, _) = await SeedAsync(h.Provider);
+
+ await h.Supervisor.EnsureConnectionAsync(10UL, serverId);
+
+ var state = await WaitForStateAsync(h.Provider, serverId, s => s.Status == ConnectionStatus.Connected);
+ Assert.NotNull(state);
+ Assert.Equal(7, state!.PlayerCount);
+ }
+
+ [Fact]
+ public async Task Connect_AuthRejected_FailsOverToNextStandbyAndDmsOwner()
+ {
+ var source = new FakeRustSocketSource();
+ source.EnqueueConnect(SocketConnectOutcome.AuthRejected); // credential A
+ source.EnqueueConnect(SocketConnectOutcome.Connected); // credential B
+ source.EnqueueHeartbeat(HeartbeatResult.Ok(3));
+ await using var h = CreateHarness(source);
+ var (serverId, credA, credB) = await SeedAsync(h.Provider);
+
+ await h.Supervisor.EnsureConnectionAsync(10UL, serverId);
+
+ var state = await WaitForStateAsync(h.Provider, serverId, s => s.Status == ConnectionStatus.Connected);
+ Assert.NotNull(state);
+ Assert.Equal(CredentialStatus.Invalid, await CredStatusAsync(h.Provider, credA));
+ Assert.Equal(CredentialStatus.Active, await CredStatusAsync(h.Provider, credB));
+ await h.Dm.Received(1).SendAsync(1UL, Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Connect_AllCredentialsRejected_BecomesNoCredentials()
+ {
+ var source = new FakeRustSocketSource();
+ source.EnqueueConnect(SocketConnectOutcome.AuthRejected); // only credential, A
+ await using var h = CreateHarness(source);
+ var (serverId, credA, _) = await SeedAsync(h.Provider, bStatus: CredentialStatus.Invalid);
+
+ await h.Supervisor.EnsureConnectionAsync(10UL, serverId);
+
+ var state = await WaitForStateAsync(h.Provider, serverId, s => s.Status == ConnectionStatus.NoCredentials);
+ Assert.NotNull(state);
+ Assert.Equal(CredentialStatus.Invalid, await CredStatusAsync(h.Provider, credA));
+ }
+
+ [Fact]
+ public async Task Connect_UnreachableThenConnected_RetriesAndRecovers()
+ {
+ var source = new FakeRustSocketSource();
+ source.EnqueueConnect(SocketConnectOutcome.Unreachable);
+ source.EnqueueConnect(SocketConnectOutcome.Connected);
+ source.EnqueueHeartbeat(HeartbeatResult.Ok(1));
+ await using var h = CreateHarness(source);
+ var (serverId, _, _) = await SeedAsync(h.Provider);
+
+ await h.Supervisor.EnsureConnectionAsync(10UL, serverId);
+
+ var state = await WaitForStateAsync(h.Provider, serverId, s => s.Status == ConnectionStatus.Connected);
+ Assert.NotNull(state);
+ Assert.True(source.CreateCount >= 2);
+ }
+
+ [Fact]
+ public async Task Heartbeat_Unreachable_ReconnectsAndRecovers()
+ {
+ 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);
+
+ await h.Supervisor.EnsureConnectionAsync(10UL, serverId);
+
+ var recovered = await WaitForStateAsync(
+ h.Provider, serverId, s => s.Status == ConnectionStatus.Connected && s.PlayerCount == 4);
+ Assert.NotNull(recovered);
+ Assert.True(source.CreateCount >= 2);
+ }
+
+ [Fact]
+ public async Task StartAll_StartsAConnectionPerConnectableServer()
+ {
+ var source = new FakeRustSocketSource();
+ source.EnqueueConnect(SocketConnectOutcome.Connected);
+ await using var h = CreateHarness(source);
+ await SeedAsync(h.Provider);
+
+ await h.Supervisor.StartAllAsync();
+
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(30);
+ while (source.CreateCount < 1 && DateTimeOffset.UtcNow < deadline)
+ {
+ await Task.Delay(15);
+ }
+
+ Assert.True(source.CreateCount >= 1);
+ }
+
+ private sealed class Harness : IAsyncDisposable
+ {
+ public required ServiceProvider Provider { get; init; }
+ public required IUserDmSender Dm { get; init; }
+ public required ConnectionSupervisor Supervisor { get; init; }
+
+ public async ValueTask DisposeAsync()
+ {
+ await Supervisor.StopAllAsync();
+ await Provider.DisposeAsync();
+ }
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
new file mode 100644
index 00000000..e9bb9047
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
@@ -0,0 +1,69 @@
+using System.Collections.Concurrent;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Connections.Tests.Fakes;
+
+///
+/// Scripted . Each dequeues the next connect outcome;
+/// each created connection dequeues heartbeat results (default Ok(0) when the queue empties).
+///
+///
+/// Connect outcomes and heartbeat results are single FIFO queues shared by every connection this
+/// source creates, consumed in enqueue order. This is intentional: the supervisor tests drive one
+/// server at a time and enqueue a deterministic sequence that must flow across reconnections of that
+/// server. Tests that need concurrent multi-server interleaving would require per-connection queues.
+/// Once the heartbeat queue is exhausted, the fake HOLDS the last dequeued heartbeat (mimicking a
+/// healthy server that keeps reporting the same state), rather than falling back to Ok(0).
+///
+internal sealed class FakeRustSocketSource : IRustSocketSource
+{
+ private readonly ConcurrentQueue _connectOutcomes = new();
+ private readonly ConcurrentQueue _heartbeats = new();
+ private int _createCount;
+
+ private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0);
+
+ /// Number of times has been called. Safe to read from any thread.
+ public int CreateCount => Volatile.Read(ref _createCount);
+
+ /// The IP address passed to the most recent call. Read after the operation under test has settled.
+ public string? LastIp { get; private set; }
+
+ /// The Steam ID passed to the most recent call. Read after the operation under test has settled.
+ public ulong LastSteamId { get; private set; }
+
+ public IRustServerConnection Create(string ip, int port, ulong steamId, string playerToken)
+ {
+ Interlocked.Increment(ref _createCount);
+ LastIp = ip;
+ LastSteamId = steamId;
+ var outcome = _connectOutcomes.TryDequeue(out var next) ? next : SocketConnectOutcome.Connected;
+ return new FakeConnection(outcome, this);
+ }
+
+ public void EnqueueConnect(SocketConnectOutcome outcome) => _connectOutcomes.Enqueue(outcome);
+
+ public void EnqueueHeartbeat(HeartbeatResult result) => _heartbeats.Enqueue(result);
+
+ internal HeartbeatResult NextHeartbeat()
+ {
+ if (_heartbeats.TryDequeue(out var next))
+ {
+ _lastHeartbeat = next;
+ }
+
+ return _lastHeartbeat;
+ }
+
+ private sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocketSource source)
+ : IRustServerConnection
+ {
+ public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
+ Task.FromResult(outcome);
+
+ public Task GetInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
+ Task.FromResult(source.NextHeartbeat());
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj b/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj
new file mode 100644
index 00000000..7f286737
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/RustPlusBot.Features.Connections.Tests/RustPlusSocketSourceTests.cs b/tests/RustPlusBot.Features.Connections.Tests/RustPlusSocketSourceTests.cs
new file mode 100644
index 00000000..5528d113
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/RustPlusSocketSourceTests.cs
@@ -0,0 +1,29 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Connections.Tests;
+
+public sealed class RustPlusSocketSourceTests
+{
+ [Fact]
+ public async Task Create_ReturnsADisposableConnection()
+ {
+ var source = new RustPlusSocketSource(NullLogger.Instance);
+
+ var connection = source.Create("127.0.0.1", 28015, 100UL, "12345");
+ await using var _ = connection;
+
+ Assert.NotNull(connection);
+ }
+
+ [Fact]
+ public async Task Create_NonNumericToken_YieldsAuthRejectedConnection()
+ {
+ var source = new RustPlusSocketSource(NullLogger.Instance);
+
+ var connection = source.Create("127.0.0.1", 28015, 100UL, "not-a-number");
+ await using var _ = connection;
+
+ Assert.Equal(SocketConnectOutcome.AuthRejected, await connection.ConnectAsync(TimeSpan.Zero, default));
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/SeamSmokeTests.cs b/tests/RustPlusBot.Features.Connections.Tests/SeamSmokeTests.cs
new file mode 100644
index 00000000..3cf22b86
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/SeamSmokeTests.cs
@@ -0,0 +1,21 @@
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Connections.Tests.Fakes;
+
+namespace RustPlusBot.Features.Connections.Tests;
+
+public sealed class SeamSmokeTests
+{
+ [Fact]
+ public async Task FakeSource_CreatesConnection_AndDefaultsToConnectedOkZero()
+ {
+ var source = new FakeRustSocketSource();
+
+ var connection = source.Create("1.2.3.4", 28015, 100UL, "tok");
+ await using var _ = connection;
+
+ Assert.Equal(SocketConnectOutcome.Connected, await connection.ConnectAsync(TimeSpan.Zero, default));
+ var beat = await connection.GetInfoAsync(TimeSpan.Zero, default);
+ Assert.Equal(HeartbeatKind.Ok, beat.Kind);
+ Assert.Equal(1, source.CreateCount);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/DiscordOwnerNotifierTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/DiscordOwnerNotifierTests.cs
new file mode 100644
index 00000000..b0c7822c
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/DiscordOwnerNotifierTests.cs
@@ -0,0 +1,22 @@
+using NSubstitute;
+using RustPlusBot.Discord.Notifications;
+using RustPlusBot.Features.Pairing.Notifications;
+
+namespace RustPlusBot.Features.Pairing.Tests;
+
+public sealed class DiscordOwnerNotifierTests
+{
+ [Fact]
+ public async Task NotifyCredentialsExpired_DmsTheOwner()
+ {
+ var dm = Substitute.For();
+ var notifier = new DiscordOwnerNotifier(dm);
+
+ await notifier.NotifyCredentialsExpiredAsync(10UL, 99UL);
+
+ await dm.Received(1).SendAsync(
+ 99UL,
+ Arg.Is(m => m.Contains("Reconnect", StringComparison.Ordinal)),
+ Arg.Any());
+ }
+}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs
index 891283cf..5da9d524 100644
--- a/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs
@@ -4,6 +4,7 @@
using RustPlusBot.Abstractions.Credentials;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Discord.Notifications;
using RustPlusBot.Features.Pairing;
using RustPlusBot.Features.Pairing.Pairing;
using RustPlusBot.Features.Pairing.Supervisor;
@@ -21,6 +22,7 @@ public async Task Services_Resolve()
services.AddSingleton();
services.AddSingleton();
services.AddSingleton(Substitute.For());
+ services.AddSingleton(Substitute.For());
services.AddLogging();
services.AddBotPersistence("DataSource=:memory:");
services.AddOptions();
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs
new file mode 100644
index 00000000..5ae3d204
--- /dev/null
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs
@@ -0,0 +1,43 @@
+using Discord.WebSocket;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Workspace.Hosting;
+using RustPlusBot.Features.Workspace.Reconciler;
+
+namespace RustPlusBot.Features.Workspace.Tests.Hosting;
+
+public sealed class WorkspaceConnectionStatusTests
+{
+ [Fact]
+ public async Task ConnectionStatusChanged_ReconcilesThatServer()
+ {
+ var reconciler = Substitute.For();
+ var services = new ServiceCollection();
+ services.AddScoped(_ => reconciler);
+ await using var provider = services.BuildServiceProvider();
+
+ var bus = new InMemoryEventBus();
+ var client = new DiscordSocketClient();
+ var service = new WorkspaceHostedService(client, bus,
+ provider.GetRequiredService(),
+ NullLogger.Instance);
+
+ await service.StartAsync(default);
+ var serverId = Guid.NewGuid();
+ // The in-process bus does not replay; re-publish until the consumer has reconciled (or deadline).
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(20);
+ while (DateTimeOffset.UtcNow < deadline
+ && !reconciler.ReceivedCalls().Any(c =>
+ c.GetMethodInfo().Name == nameof(IWorkspaceReconciler.ReconcileServerAsync)))
+ {
+ await bus.PublishAsync(new ConnectionStatusChangedEvent(10UL, serverId));
+ await Task.Delay(20);
+ }
+
+ await reconciler.Received().ReconcileServerAsync(10UL, serverId, Arg.Any());
+
+ await service.StopAsync(default);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs
index 37e17cd3..d7d341a2 100644
--- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs
@@ -1,10 +1,14 @@
using Discord;
using NSubstitute;
+using RustPlusBot.Domain.Connections;
+using RustPlusBot.Domain.Credentials;
using RustPlusBot.Domain.Servers;
using RustPlusBot.Features.Workspace.Localization;
using RustPlusBot.Features.Workspace.Messages;
using RustPlusBot.Features.Workspace.Registry;
+using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Servers;
+using DomainConnectionState = RustPlusBot.Domain.Connections.ConnectionState;
namespace RustPlusBot.Features.Workspace.Tests.Messages;
@@ -55,9 +59,10 @@ public async Task Settings_HasLanguageSelectMenu()
}
[Fact]
- public async Task ServerInfo_TitleIsServerName_AndEndpointShown()
+ public async Task ServerInfo_Connected_ShowsStatusActivePlayerAndCount_AndSwapSelect()
{
var serverId = Guid.NewGuid();
+ var credId = Guid.NewGuid();
var servers = Substitute.For();
servers.GetAsync(1, serverId, Arg.Any())
.Returns(new RustServer
@@ -68,12 +73,101 @@ public async Task ServerInfo_TitleIsServerName_AndEndpointShown()
Ip = "1.2.3.4",
Port = 28015
});
- var renderer = new ServerInfoMessageRenderer(servers, Loc);
+
+ var connections = Substitute.For();
+ connections.GetStateAsync(1, serverId, Arg.Any())
+ .Returns(new DomainConnectionState
+ {
+ RustServerId = serverId,
+ GuildId = 1,
+ ActiveCredentialId = credId,
+ Status = ConnectionStatus.Connected,
+ PlayerCount = 12,
+ });
+ connections.ListPoolAsync(1, serverId, Arg.Any())
+ .Returns(new List
+ {
+ new()
+ {
+ Id = credId,
+ GuildId = 1,
+ RustServerId = serverId,
+ OwnerUserId = 7,
+ SteamId = 76561198000000000UL,
+ Status = CredentialStatus.Active
+ },
+ });
+
+ var renderer = new ServerInfoMessageRenderer(servers, connections, Loc);
+
+ var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default);
+
+ Assert.Contains("Rustopia EU", payload.Embed!.Title, StringComparison.Ordinal);
+ Assert.Contains("12", string.Concat(payload.Embed.Fields.Select(f => f.Value)), StringComparison.Ordinal);
+ Assert.Contains("76561198000000000", string.Concat(payload.Embed.Fields.Select(f => f.Value)),
+ StringComparison.Ordinal);
+ var selects = payload.Components!.Components.OfType()
+ .SelectMany(r => r.Components).OfType();
+ Assert.Contains(selects, s => s.CustomId == $"workspace:info:swap:{serverId}");
+ }
+
+ [Fact]
+ public async Task ServerInfo_NoCredentials_ShowsNoCredentialsAndNoCount()
+ {
+ var serverId = Guid.NewGuid();
+ var servers = Substitute.For();
+ servers.GetAsync(1, serverId, Arg.Any())
+ .Returns(new RustServer
+ {
+ Id = serverId,
+ GuildId = 1,
+ Name = "S",
+ Ip = "1.2.3.4",
+ Port = 28015
+ });
+ var connections = Substitute.For();
+ connections.GetStateAsync(1, serverId, Arg.Any())
+ .Returns(new DomainConnectionState
+ {
+ RustServerId = serverId, GuildId = 1, Status = ConnectionStatus.NoCredentials
+ });
+ connections.ListPoolAsync(1, serverId, Arg.Any())
+ .Returns(new List());
+
+ var renderer = new ServerInfoMessageRenderer(servers, connections, Loc);
+
+ var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default);
+
+ Assert.Contains("No working credentials",
+ string.Concat(payload.Embed!.Fields.Select(f => f.Value)), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task ServerInfo_NullState_DefaultsToNoCredentials()
+ {
+ var serverId = Guid.NewGuid();
+ var servers = Substitute.For();
+ servers.GetAsync(1, serverId, Arg.Any())
+ .Returns(new RustServer
+ {
+ Id = serverId,
+ GuildId = 1,
+ Name = "S",
+ Ip = "1.2.3.4",
+ Port = 28015
+ });
+ var connections = Substitute.For();
+ connections.GetStateAsync(1, serverId, Arg.Any())
+ .Returns((DomainConnectionState?)null);
+ connections.ListPoolAsync(1, serverId, Arg.Any())
+ .Returns(new List());
+
+ var renderer = new ServerInfoMessageRenderer(servers, connections, Loc);
var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default);
- Assert.Equal("Rustopia EU", payload.Embed!.Title);
- Assert.Contains("1.2.3.4", payload.Embed.Description, StringComparison.Ordinal);
+ Assert.Contains("No working credentials",
+ string.Concat(payload.Embed!.Fields.Select(f => f.Value)), StringComparison.Ordinal);
}
[Fact]
diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs
new file mode 100644
index 00000000..fba322d3
--- /dev/null
+++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs
@@ -0,0 +1,52 @@
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Domain.Connections;
+
+namespace RustPlusBot.Persistence.Tests.Connections;
+
+public sealed class ConnectionStateSchemaTests
+{
+ [Fact]
+ public async Task ConnectionState_RoundTrips_StatusAndPlayerCount()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+
+ var serverId = Guid.NewGuid();
+ context.ConnectionStates.Add(new ConnectionState
+ {
+ RustServerId = serverId,
+ GuildId = 10UL,
+ ActiveCredentialId = Guid.NewGuid(),
+ Status = ConnectionStatus.Connected,
+ PlayerCount = 42,
+ UpdatedAt = DateTimeOffset.UnixEpoch,
+ });
+ await context.SaveChangesAsync();
+
+ var read = await context.ConnectionStates.SingleAsync(s => s.RustServerId == serverId);
+ Assert.Equal(ConnectionStatus.Connected, read.Status);
+ Assert.Equal(42, read.PlayerCount);
+ }
+
+ [Fact]
+ public async Task ConnectionState_RoundTrips_NullPlayerCount()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+
+ var serverId = Guid.NewGuid();
+ context.ConnectionStates.Add(new ConnectionState
+ {
+ RustServerId = serverId,
+ GuildId = 10UL,
+ Status = ConnectionStatus.Connecting,
+ UpdatedAt = DateTimeOffset.UnixEpoch,
+ });
+ await context.SaveChangesAsync();
+
+ var read = await context.ConnectionStates.SingleAsync(s => s.RustServerId == serverId);
+ Assert.Null(read.PlayerCount);
+ }
+}
diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs
new file mode 100644
index 00000000..4a630797
--- /dev/null
+++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs
@@ -0,0 +1,144 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using NSubstitute;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Connections;
+using RustPlusBot.Domain.Credentials;
+using RustPlusBot.Domain.Servers;
+using RustPlusBot.Persistence.Connections;
+
+namespace RustPlusBot.Persistence.Tests.Connections;
+
+public sealed class ConnectionStoreTests
+{
+ private static (ConnectionStore Store, BotDbContext Context, SqliteConnection Conn) Create()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ var clock = Substitute.For();
+ clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
+ return (new ConnectionStore(context, clock), context, connection);
+ }
+
+ private static async Task<(Guid ServerId, Guid CredA, Guid CredB)> SeedServerWithPoolAsync(BotDbContext context)
+ {
+ var server = new RustServer
+ {
+ GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ var a = new PlayerCredential
+ {
+ GuildId = 10UL,
+ RustServerId = server.Id,
+ OwnerUserId = 1UL,
+ SteamId = 100UL,
+ ProtectedPlayerToken = "ta",
+ Status = CredentialStatus.Active,
+ };
+ var b = new PlayerCredential
+ {
+ GuildId = 10UL,
+ RustServerId = server.Id,
+ OwnerUserId = 2UL,
+ SteamId = 200UL,
+ ProtectedPlayerToken = "tb",
+ Status = CredentialStatus.Standby,
+ };
+ await context.PlayerCredentials.AddRangeAsync(a, b);
+ await context.SaveChangesAsync();
+ return (server.Id, a.Id, b.Id);
+ }
+
+ [Fact]
+ public async Task UpsertStatus_InsertsThenReportsChangeOnlyWhenDifferent()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+ var serverId = Guid.NewGuid();
+
+ Assert.True(await store.UpsertStatusAsync(10UL, serverId, ConnectionStatus.Connecting, null, null));
+ Assert.True(await store.UpsertStatusAsync(10UL, serverId, ConnectionStatus.Connected, 5, null));
+ Assert.False(await store.UpsertStatusAsync(10UL, serverId, ConnectionStatus.Connected, 5, null));
+ Assert.True(await store.UpsertStatusAsync(10UL, serverId, ConnectionStatus.Connected, 6, null));
+
+ var state = await store.GetStateAsync(10UL, serverId);
+ Assert.Equal(ConnectionStatus.Connected, state!.Status);
+ Assert.Equal(6, state.PlayerCount);
+ }
+
+ [Fact]
+ public async Task GetActiveCredential_ReturnsTheActiveOne()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+ var (serverId, credA, _) = await SeedServerWithPoolAsync(context);
+
+ var active = await store.GetActiveCredentialAsync(10UL, serverId);
+
+ Assert.Equal(credA, active!.Id);
+ Assert.Equal("ta", active.ProtectedPlayerToken);
+ }
+
+ [Fact]
+ public async Task Promote_MakesTargetActiveAndDemotesPrior()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+ var (serverId, credA, credB) = await SeedServerWithPoolAsync(context);
+
+ var ok = await store.PromoteAsync(10UL, serverId, credB);
+
+ Assert.True(ok);
+ var pool = await store.ListPoolAsync(10UL, serverId);
+ Assert.Equal(CredentialStatus.Active, pool.Single(c => c.Id == credB).Status);
+ Assert.Equal(CredentialStatus.Standby, pool.Single(c => c.Id == credA).Status);
+ }
+
+ [Fact]
+ public async Task Promote_RejectsInvalidOrUnknownCredential()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+ var (serverId, credA, _) = await SeedServerWithPoolAsync(context);
+ await store.MarkInvalidAsync(credA);
+
+ Assert.False(await store.PromoteAsync(10UL, serverId, credA));
+ Assert.False(await store.PromoteAsync(10UL, serverId, Guid.NewGuid()));
+ }
+
+ [Fact]
+ public async Task MarkInvalid_SetsStatusInvalid()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+ var (serverId, credA, _) = await SeedServerWithPoolAsync(context);
+
+ await store.MarkInvalidAsync(credA);
+
+ var pool = await store.ListPoolAsync(10UL, serverId);
+ Assert.Equal(CredentialStatus.Invalid, pool.Single(c => c.Id == credA).Status);
+ }
+
+ [Fact]
+ public async Task ListConnectableServers_ExcludesAllInvalidServers()
+ {
+ var (store, context, conn) = Create();
+ await using var _ = conn;
+ await using var __ = context;
+ var (serverId, credA, credB) = await SeedServerWithPoolAsync(context);
+
+ var before = await store.ListConnectableServersAsync();
+ Assert.Contains((10UL, serverId), before);
+
+ await store.MarkInvalidAsync(credA);
+ await store.MarkInvalidAsync(credB);
+
+ var after = await store.ListConnectableServersAsync();
+ Assert.DoesNotContain((10UL, serverId), after);
+ }
+}
diff --git a/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs b/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs
index f811dc00..c410299e 100644
--- a/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs
+++ b/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs
@@ -2,6 +2,7 @@
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Abstractions.Credentials;
using RustPlusBot.Persistence;
+using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Credentials;
using RustPlusBot.Persistence.Servers;
@@ -31,5 +32,6 @@ public void AddBotPersistence_RegistersDbContextFactoryAndScopedServices()
// supplied by the Host, so verify the registration descriptor rather than resolving it.
Assert.Contains(services, d => d.ServiceType == typeof(ICredentialStore));
Assert.Contains(services, d => d.ServiceType == typeof(IFcmRegistrationStore));
+ Assert.Contains(services, d => d.ServiceType == typeof(IConnectionStore));
}
}