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