diff --git a/Directory.Packages.props b/Directory.Packages.props
index df46bc64..05408d4a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,6 +11,7 @@
+
diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx
index dce34291..6a828257 100644
--- a/RustPlusBot.slnx
+++ b/RustPlusBot.slnx
@@ -4,11 +4,13 @@
+
+
diff --git a/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs b/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs
index c01f8508..09be754b 100644
--- a/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs
+++ b/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs
@@ -1,13 +1,23 @@
namespace RustPlusBot.Abstractions.Credentials;
-/// Persists and counts player credentials. Tokens are protected at rest.
+/// Persists and counts per-server player credentials. Tokens are protected at rest.
public interface ICredentialStore
{
- /// Stores a credential (as Standby) and returns its new id.
+ ///
+ /// Inserts or refreshes the credential for (GuildId, RustServerId, OwnerUserId). A NEWLY INSERTED
+ /// credential is Active when is true (the owner whose pairing
+ /// first registered the server), otherwise Standby. Re-pairing refreshes the SteamId and token
+ /// and resets an Invalid credential to Standby (the existing designation is preserved;
+ /// is ignored on update). Returns the credential's id.
+ ///
/// The plaintext credential inputs.
+ /// When inserting, whether this credential becomes the server's active identity.
/// A cancellation token.
- /// The new credential's id.
- Task StoreAsync(StoreCredentialRequest request, CancellationToken cancellationToken = default);
+ /// The credential's id.
+ Task UpsertFromPairingAsync(
+ StoreCredentialRequest request,
+ bool markActive,
+ CancellationToken cancellationToken = default);
/// Counts stored credentials for a server within a guild.
/// Owning Discord guild snowflake.
diff --git a/src/RustPlusBot.Abstractions/Credentials/StoreCredentialRequest.cs b/src/RustPlusBot.Abstractions/Credentials/StoreCredentialRequest.cs
index e46919f2..4a4617fd 100644
--- a/src/RustPlusBot.Abstractions/Credentials/StoreCredentialRequest.cs
+++ b/src/RustPlusBot.Abstractions/Credentials/StoreCredentialRequest.cs
@@ -1,16 +1,14 @@
namespace RustPlusBot.Abstractions.Credentials;
-/// Plaintext inputs to register a credential; tokens are protected by the store before persistence.
+/// Plaintext inputs to upsert a per-server player credential from a pairing; the token is protected before persistence.
/// Owning Discord guild snowflake.
/// The server this credential connects to.
-/// The Discord user registering the credential.
-/// The player's Steam64 id.
+/// The Discord user who owns the paired identity.
+/// The player's Steam64 id (from the pairing notification).
/// The plaintext Rust+ player token (protected before storage).
-/// The plaintext FCM/Expo credential JSON (protected before storage).
public sealed record StoreCredentialRequest(
ulong GuildId,
Guid RustServerId,
ulong OwnerUserId,
ulong SteamId,
- string PlayerToken,
- string FcmCredentialsJson);
+ string PlayerToken);
diff --git a/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs
new file mode 100644
index 00000000..7e94458c
--- /dev/null
+++ b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs
@@ -0,0 +1,26 @@
+namespace RustPlusBot.Domain.Credentials;
+
+///
+/// One Discord user's Rust+ FCM listener registration within a guild. One per (GuildId, OwnerUserId).
+/// The credentials blob is stored protected at rest (see ICredentialProtector) and feeds the pairing listener.
+///
+public sealed class FcmRegistration
+{
+ /// Surrogate primary key.
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
+
+ /// The Discord user who connected these credentials.
+ public ulong OwnerUserId { get; set; }
+
+ /// Protected FCM/Expo credentials JSON.
+ public string ProtectedFcmCredentials { get; set; } = string.Empty;
+
+ /// Listener lifecycle state.
+ public FcmRegistrationStatus Status { get; set; } = FcmRegistrationStatus.Active;
+
+ /// When the registration was last upserted or had its status changed (UTC).
+ public DateTimeOffset UpdatedAt { get; set; }
+}
diff --git a/src/RustPlusBot.Domain/Credentials/FcmRegistrationStatus.cs b/src/RustPlusBot.Domain/Credentials/FcmRegistrationStatus.cs
new file mode 100644
index 00000000..3deab34d
--- /dev/null
+++ b/src/RustPlusBot.Domain/Credentials/FcmRegistrationStatus.cs
@@ -0,0 +1,14 @@
+namespace RustPlusBot.Domain.Credentials;
+
+/// Lifecycle state of a user's FCM listener registration.
+public enum FcmRegistrationStatus
+{
+ /// Credentials accepted; the listener should run.
+ Active = 0,
+
+ /// FCM rejected the credentials; the user must reconnect.
+ Expired = 1,
+
+ /// Removed by the user; the listener must not run.
+ Disabled = 2,
+}
diff --git a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs
index 2f0de779..f2b34a6b 100644
--- a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs
+++ b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs
@@ -24,9 +24,6 @@ public sealed class PlayerCredential
/// Protected Rust+ player token.
public string ProtectedPlayerToken { get; set; } = string.Empty;
- /// Protected FCM/Expo credential blob (JSON), used by the pairing listener later.
- public string ProtectedFcmCredentials { get; set; } = string.Empty;
-
/// Pool lifecycle state.
public CredentialStatus Status { get; set; } = CredentialStatus.Standby;
}
diff --git a/src/RustPlusBot.Features.Pairing/AssemblyInfo.cs b/src/RustPlusBot.Features.Pairing/AssemblyInfo.cs
new file mode 100644
index 00000000..8089bbc1
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("RustPlusBot.Features.Pairing.Tests")]
diff --git a/src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs b/src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs
new file mode 100644
index 00000000..b030a5e7
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs
@@ -0,0 +1,58 @@
+using Discord.WebSocket;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Features.Pairing.Supervisor;
+
+namespace RustPlusBot.Features.Pairing.Hosting;
+
+/// Drives the supervisor from the host lifecycle: start listeners on Ready, stop them on shutdown.
+/// The socket client (for the Ready event).
+/// The pairing supervisor.
+/// The logger.
+internal sealed partial class PairingHostedService(
+ DiscordSocketClient client,
+ IPairingSupervisor supervisor,
+ ILogger logger) : IHostedService
+{
+ private bool _started;
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ client.Ready += OnReadyAsync;
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ client.Ready -= OnReadyAsync;
+ await supervisor.StopAllAsync().ConfigureAwait(false);
+ }
+
+ private async Task OnReadyAsync()
+ {
+ // Ready fires on every (re)connect; only start listeners once per process. Ready is dispatched
+ // serially on the gateway thread, so the plain bool guard needs no synchronization (same pattern
+ // as DiscordBotService and WorkspaceHostedService).
+ if (_started)
+ {
+ return;
+ }
+
+ _started = true;
+ try
+ {
+ await supervisor.StartAllActiveAsync().ConfigureAwait(false);
+ }
+#pragma warning disable CA1031 // Broad catch is intentional: a failed startup must not crash the host.
+ catch (Exception ex)
+ {
+ LogStartupFailed(ex);
+ }
+#pragma warning restore CA1031
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Failed to start FCM pairing listeners.")]
+ private partial void LogStartupFailed(Exception exception);
+}
diff --git a/src/RustPlusBot.Features.Pairing/Listening/IPairingSource.cs b/src/RustPlusBot.Features.Pairing/Listening/IPairingSource.cs
new file mode 100644
index 00000000..202ef3dc
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Listening/IPairingSource.cs
@@ -0,0 +1,27 @@
+namespace RustPlusBot.Features.Pairing.Listening;
+
+/// A live FCM listener for one user's credentials. Pushes pairing notifications to a callback.
+internal interface IPairingListener : IAsyncDisposable
+{
+ ///
+ /// Connects and waits up to for the first successful check-in. After a
+ /// result the listener keeps delivering notifications
+ /// to its callback until disposed.
+ ///
+ /// How long to wait for the initial check-in.
+ /// Cancels the connection.
+ /// The initial connect outcome.
+ Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken);
+}
+
+/// Creates s from credentials. The seam that abstracts FCM for tests.
+internal interface IPairingSource
+{
+ /// Creates a listener that delivers notifications to .
+ /// The plaintext FCM credentials JSON.
+ /// Invoked for every pairing notification received.
+ /// A not-yet-connected listener.
+ IPairingListener Create(
+ string fcmCredentialsJson,
+ Func onNotification);
+}
diff --git a/src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs b/src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs
new file mode 100644
index 00000000..73482a17
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs
@@ -0,0 +1,39 @@
+namespace RustPlusBot.Features.Pairing.Listening;
+
+/// Whether a pairing notification is for a server or an in-game entity.
+internal enum PairingKind
+{
+ /// A server pairing (registers a server).
+ Server = 0,
+
+ /// An entity pairing (switch/alarm/camera) — ignored in 1b-i.
+ Entity = 1,
+}
+
+/// The outcome of an initial FCM connect attempt.
+internal enum PairingConnectOutcome
+{
+ /// Connected and checked in.
+ Connected = 0,
+
+ /// FCM rejected the credentials.
+ Rejected = 1,
+
+ /// No result within the probe window (network); retried in the background.
+ Timeout = 2,
+}
+
+/// A pairing notification delivered by a listener.
+/// Server or entity.
+/// The server's display name.
+/// The server host or ip.
+/// The Rust+ app port.
+/// The paired player's Steam64 id.
+/// The Rust+ player token for this (server, player).
+internal sealed record PairingNotification(
+ PairingKind Kind,
+ string ServerName,
+ string Ip,
+ int Port,
+ ulong PlayerId,
+ string PlayerToken);
diff --git a/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs b/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs
new file mode 100644
index 00000000..30539589
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs
@@ -0,0 +1,146 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using RustPlusApi.Fcm;
+using RustPlusApi.Fcm.Data;
+using RustPlusApi.Fcm.Data.Events;
+
+namespace RustPlusBot.Features.Pairing.Listening;
+
+/// Real backed by RustPlusApi.Fcm. Untested by unit tests (integration shim).
+/// The logger.
+internal sealed partial class RustPlusFcmPairingSource(ILogger logger) : IPairingSource
+{
+ ///
+ public IPairingListener Create(
+ string fcmCredentialsJson,
+ Func onNotification)
+ {
+ try
+ {
+ return new RustPlusFcmListener(fcmCredentialsJson, onNotification, logger);
+ }
+#pragma warning disable CA1031 // Broad catch: a malformed credential blob must surface as Rejected, not fault the listener loop.
+ catch (Exception ex)
+ {
+ LogConstructionFailed(ex);
+ return new RejectedListener();
+ }
+#pragma warning restore CA1031
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning,
+ Message = "FCM listener construction failed; treating credentials as rejected.")]
+ private partial void LogConstructionFailed(Exception exception);
+
+ /// A listener returned when credential construction failed; reports Rejected and does nothing else.
+ private sealed class RejectedListener : IPairingListener
+ {
+ ///
+ public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
+ Task.FromResult(PairingConnectOutcome.Rejected);
+
+ ///
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+
+ private sealed partial class RustPlusFcmListener : IPairingListener
+ {
+ private static readonly JsonSerializerOptions JsonOptions =
+ new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly RustPlusFcm _fcm;
+ private readonly ILogger _logger;
+ private readonly Func _onNotification;
+
+ public RustPlusFcmListener(
+ string fcmCredentialsJson,
+ Func onNotification,
+ ILogger logger)
+ {
+ var credentials = JsonSerializer.Deserialize(fcmCredentialsJson, JsonOptions)
+ ?? throw new ArgumentException("FCM credentials JSON deserialized to null.",
+ nameof(fcmCredentialsJson));
+
+ _onNotification = onNotification;
+ _logger = logger;
+ _fcm = new RustPlusFcm(credentials, persistentIds: null, options: null, loggerFactory: null);
+ _fcm.OnServerPairing += OnServerPairing;
+ }
+
+ ///
+ public async Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(timeout);
+
+ try
+ {
+ await _fcm.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
+ return PairingConnectOutcome.Connected;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ // Timed out waiting for the connection — not the caller's CancellationToken.
+ return PairingConnectOutcome.Timeout;
+ }
+#pragma warning disable CA1031 // Broad catch is intentional: any non-cancellation exception (auth, TLS, protocol error) maps to Rejected.
+ catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
+#pragma warning restore CA1031
+ {
+ LogConnectionFailed(_logger, ex);
+ return PairingConnectOutcome.Rejected;
+ }
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ _fcm.OnServerPairing -= OnServerPairing;
+ await _fcm.DisposeAsync().ConfigureAwait(false);
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "FCM credentials rejected or connection failed.")]
+ private static partial void LogConnectionFailed(ILogger logger, Exception ex);
+
+ [LoggerMessage(Level = LogLevel.Warning,
+ Message = "Exception while dispatching pairing notification; it is swallowed to keep the listener alive.")]
+ private static partial void LogNotificationDispatchFailed(ILogger logger, Exception ex);
+
+ private void OnServerPairing(object? sender, Notification e)
+ {
+ if (e?.Data is null)
+ {
+ return;
+ }
+
+ var notification = new PairingNotification(
+ Kind: PairingKind.Server,
+ ServerName: e.Data.Name ?? string.Empty,
+ Ip: e.Data.Ip ?? string.Empty,
+ Port: e.Data.Port,
+ PlayerId: e.PlayerId,
+ PlayerToken: e.PlayerToken.ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ // Fire-and-forget bridge from synchronous event to async callback.
+ // Exception must never propagate back into the package's event dispatcher.
+#pragma warning disable CA2008 // Task.Run without TaskScheduler: acceptable here; default is ThreadPool.
+ _ = Task.Run(async () =>
+#pragma warning restore CA2008
+ {
+ try
+ {
+ await _onNotification(notification, CancellationToken.None).ConfigureAwait(false);
+ }
+#pragma warning disable CA1031 // Broad catch is intentional: an unhandled exception here would silently fault an unobserved task, crashing the process.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogNotificationDispatchFailed(_logger, ex);
+ }
+ });
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Pairing/Modules/ConnectModal.cs b/src/RustPlusBot.Features.Pairing/Modules/ConnectModal.cs
new file mode 100644
index 00000000..2545dad9
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Modules/ConnectModal.cs
@@ -0,0 +1,23 @@
+using Discord;
+using Discord.Interactions;
+
+namespace RustPlusBot.Features.Pairing.Modules;
+
+/// The ephemeral modal that collects a user's FCM credentials JSON.
+public sealed class ConnectModal : IModal
+{
+ /// Custom id of the modal, handled by .
+ public const string ModalId = "pairing:connect:modal";
+
+ /// Custom id of the credentials text input.
+ public const string CredentialsInputId = "pairing:connect:credentials";
+
+ /// The pasted FCM credentials JSON.
+ [InputLabel("FCM credentials JSON")]
+ [ModalTextInput(CredentialsInputId, TextInputStyle.Paragraph,
+ placeholder: "Paste the JSON from the credentials helper")]
+ public string Credentials { get; set; } = string.Empty;
+
+ ///
+ public string Title => "Connect your Rust+ account";
+}
diff --git a/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs
new file mode 100644
index 00000000..03ace00e
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs
@@ -0,0 +1,71 @@
+using Discord.Interactions;
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Features.Pairing.Listening;
+using RustPlusBot.Features.Pairing.Supervisor;
+using RustPlusBot.Features.Pairing.Validation;
+using RustPlusBot.Features.Workspace;
+using RustPlusBot.Persistence.Credentials;
+
+namespace RustPlusBot.Features.Pairing.Modules;
+
+/// Handles the #setup "Connect account" button and the credentials modal submission.
+/// Creates a short-lived DI scope per interaction.
+public sealed class CredentialModule(IServiceScopeFactory scopeFactory)
+ : InteractionModuleBase
+{
+ /// Opens the credentials modal when the #setup button is clicked.
+ [ComponentInteraction(WorkspaceComponentIds.ConnectAccount)]
+ public async Task OpenAsync()
+ {
+ if (Context.Guild is null)
+ {
+ await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await RespondWithModalAsync(ConnectModal.ModalId).ConfigureAwait(false);
+ }
+
+ /// Validates, stores, and connects the submitted credentials.
+ /// The submitted modal.
+ [ModalInteraction(ConnectModal.ModalId)]
+ public async Task SubmitAsync(ConnectModal modal)
+ {
+ ArgumentNullException.ThrowIfNull(modal);
+ if (Context.Guild is null)
+ {
+ await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await DeferAsync(ephemeral: true).ConfigureAwait(false);
+
+ if (!FcmCredentialValidator.IsWellFormed(modal.Credentials))
+ {
+ await FollowupAsync(
+ "I couldn't read those credentials. Paste the full JSON produced by the credentials helper.",
+ ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var registrations = scope.ServiceProvider.GetRequiredService();
+ await registrations.UpsertAsync(Context.Guild.Id, Context.User.Id, modal.Credentials).ConfigureAwait(false);
+
+ var supervisor = scope.ServiceProvider.GetRequiredService();
+ var outcome = await supervisor.EnsureListenerAsync(Context.Guild.Id, Context.User.Id).ConfigureAwait(false);
+
+ var message = outcome switch
+ {
+ PairingConnectOutcome.Connected =>
+ "Account connected. Pair with a server in-game and it'll show up here automatically.",
+ PairingConnectOutcome.Rejected =>
+ "Those credentials were rejected by Rust+. Generate fresh credentials and reconnect.",
+ _ => "Account saved — still verifying the connection. Pairings will appear once it's up.",
+ };
+ await FollowupAsync(message, ephemeral: true).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs b/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs
new file mode 100644
index 00000000..ba1713dc
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs
@@ -0,0 +1,45 @@
+using Discord;
+using Discord.WebSocket;
+using Microsoft.Extensions.Logging;
+
+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
+{
+ ///
+ public async 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);
+}
diff --git a/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs b/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs
new file mode 100644
index 00000000..e25ce220
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs
@@ -0,0 +1,11 @@
+namespace RustPlusBot.Features.Pairing.Notifications;
+
+/// Notifies a credential owner out-of-band (e.g. by DM) when action is needed.
+internal interface IOwnerNotifier
+{
+ /// Tells the owner their FCM credentials expired and to reconnect.
+ /// The guild the registration belongs to.
+ /// The Discord user to notify.
+ /// A cancellation token.
+ Task NotifyCredentialsExpiredAsync(ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default);
+}
diff --git a/src/RustPlusBot.Features.Pairing/Pairing/IPairingHandler.cs b/src/RustPlusBot.Features.Pairing/Pairing/IPairingHandler.cs
new file mode 100644
index 00000000..b719fe1c
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Pairing/IPairingHandler.cs
@@ -0,0 +1,18 @@
+using RustPlusBot.Features.Pairing.Listening;
+
+namespace RustPlusBot.Features.Pairing.Pairing;
+
+/// Turns a pairing notification into a registered server and a pooled credential.
+internal interface IPairingHandler
+{
+ /// Handles one notification for a given owner.
+ /// The owning guild snowflake.
+ /// The Discord user whose listener received it.
+ /// The pairing notification.
+ /// A cancellation token.
+ Task HandleAsync(
+ ulong guildId,
+ ulong ownerUserId,
+ PairingNotification notification,
+ CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
new file mode 100644
index 00000000..fe234485
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
@@ -0,0 +1,54 @@
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Pairing.Listening;
+using RustPlusBot.Persistence.Servers;
+
+namespace RustPlusBot.Features.Pairing.Pairing;
+
+/// Default : resolve-or-create the server, upsert the credential, announce new servers.
+/// Server resolve-or-create.
+/// The credential pool store.
+/// Publishes the real ServerRegisteredEvent on new-server creation.
+/// The logger.
+internal sealed partial class PairingHandler(
+ IServerService servers,
+ ICredentialStore credentials,
+ IEventBus eventBus,
+ ILogger logger) : IPairingHandler
+{
+ ///
+ public async Task HandleAsync(
+ ulong guildId,
+ ulong ownerUserId,
+ PairingNotification notification,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(notification);
+ if (notification.Kind != PairingKind.Server)
+ {
+ LogIgnoringKind(logger, notification.Kind);
+ return;
+ }
+
+ var (server, created) = await servers.ResolveOrCreateByEndpointAsync(
+ guildId, ownerUserId, notification.ServerName, notification.Ip, notification.Port, cancellationToken)
+ .ConfigureAwait(false);
+
+ await credentials.UpsertFromPairingAsync(
+ new StoreCredentialRequest(guildId, server.Id, ownerUserId, notification.PlayerId,
+ notification.PlayerToken),
+ markActive: created,
+ cancellationToken).ConfigureAwait(false);
+
+ if (created)
+ {
+ await eventBus.PublishAsync(new ServerRegisteredEvent(guildId, server.Id), cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Debug,
+ Message = "Ignoring {Kind} pairing notification (deferred to a later subsystem).")]
+ private static partial void LogIgnoringKind(ILogger logger, PairingKind kind);
+}
diff --git a/src/RustPlusBot.Features.Pairing/PairingOptions.cs b/src/RustPlusBot.Features.Pairing/PairingOptions.cs
new file mode 100644
index 00000000..cc2381c6
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/PairingOptions.cs
@@ -0,0 +1,14 @@
+namespace RustPlusBot.Features.Pairing;
+
+/// Pairing feature configuration, bound from the "Pairing" config section.
+public sealed class PairingOptions
+{
+ /// How long the modal connect waits for an initial FCM check-in before reporting "still verifying".
+ public TimeSpan ProbeTimeout { get; set; } = TimeSpan.FromSeconds(8);
+
+ /// First delay before retrying a timed-out initial connect.
+ public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromSeconds(5);
+
+ /// Cap on the exponential backoff between connect retries.
+ public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromMinutes(5);
+}
diff --git a/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs
new file mode 100644
index 00000000..ba0da9e3
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs
@@ -0,0 +1,33 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Discord;
+using RustPlusBot.Features.Pairing.Hosting;
+using RustPlusBot.Features.Pairing.Listening;
+using RustPlusBot.Features.Pairing.Notifications;
+using RustPlusBot.Features.Pairing.Pairing;
+using RustPlusBot.Features.Pairing.Supervisor;
+
+namespace RustPlusBot.Features.Pairing;
+
+/// DI registration for the pairing feature.
+public static class PairingServiceCollectionExtensions
+{
+ /// Registers the pairing source, supervisor, handler, notifier, module seam, and hosted service.
+ /// The service collection to add to.
+ /// The same service collection, for chaining.
+ public static IServiceCollection AddPairing(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Contribute this assembly's interaction modules to the Discord layer.
+ services.AddSingleton(new InteractionModuleAssembly(typeof(PairingServiceCollectionExtensions).Assembly));
+
+ services.AddHostedService();
+
+ return services;
+ }
+}
diff --git a/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj b/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj
new file mode 100644
index 00000000..dd788166
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs b/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs
new file mode 100644
index 00000000..7380754b
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs
@@ -0,0 +1,27 @@
+using RustPlusBot.Features.Pairing.Listening;
+
+namespace RustPlusBot.Features.Pairing.Supervisor;
+
+/// Owns the live FCM listeners — one per active registration.
+internal interface IPairingSupervisor
+{
+ ///
+ /// Starts (or restarts) the listener for (guild, owner) and returns the initial connect outcome.
+ /// On the registration is marked Expired and the owner notified.
+ ///
+ /// The owning guild snowflake.
+ /// The Discord user.
+ /// A cancellation token.
+ /// The initial connect outcome.
+ Task EnsureListenerAsync(
+ ulong guildId,
+ ulong ownerUserId,
+ CancellationToken cancellationToken = default);
+
+ /// Starts listeners for every Active registration (called once at startup).
+ /// A cancellation token.
+ Task StartAllActiveAsync(CancellationToken cancellationToken = default);
+
+ /// Cancels and disposes every listener (called on shutdown).
+ Task StopAllAsync();
+}
diff --git a/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs b/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs
new file mode 100644
index 00000000..09202a7a
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs
@@ -0,0 +1,286 @@
+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.Domain.Credentials;
+using RustPlusBot.Features.Pairing.Listening;
+using RustPlusBot.Features.Pairing.Notifications;
+using RustPlusBot.Features.Pairing.Pairing;
+using RustPlusBot.Persistence.Credentials;
+
+namespace RustPlusBot.Features.Pairing.Supervisor;
+
+/// Default : one connect-retry loop per active registration.
+/// Creates listeners (FCM in production, a fake in tests).
+/// Opens scopes for the scoped store and handler.
+/// Notifies owners when their credentials expire.
+/// Unprotects stored credentials before connecting.
+/// Probe timeout and backoff settings.
+/// The logger.
+internal sealed partial class PairingSupervisor(
+ IPairingSource source,
+ IServiceScopeFactory scopeFactory,
+ IOwnerNotifier notifier,
+ ICredentialProtector protector,
+ IOptions options,
+ ILogger logger) : IPairingSupervisor, IAsyncDisposable
+{
+ private readonly ConcurrentDictionary<(ulong Guild, ulong Owner), Handle> _listeners = new();
+ private readonly PairingOptions _options = options.Value;
+ private readonly CancellationTokenSource _shutdown = new();
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await StopAllAsync().ConfigureAwait(false);
+ _shutdown.Dispose();
+ }
+
+ ///
+ public async Task EnsureListenerAsync(
+ ulong guildId,
+ ulong ownerUserId,
+ CancellationToken cancellationToken = default)
+ {
+ var key = (guildId, ownerUserId);
+ await StopListenerAsync(key).ConfigureAwait(false);
+
+ FcmRegistration? registration;
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ registration = await store.GetAsync(guildId, ownerUserId, cancellationToken).ConfigureAwait(false);
+ }
+
+ if (registration is null || registration.Status == FcmRegistrationStatus.Disabled)
+ {
+ return PairingConnectOutcome.Rejected;
+ }
+
+ string credentialsJson;
+ try
+ {
+ credentialsJson = protector.Unprotect(registration.ProtectedFcmCredentials);
+ }
+ catch (CryptographicException ex)
+ {
+ LogUnreadableCredentials(logger, ex, ownerUserId);
+ await MarkExpiredAsync(key, registration.Id).ConfigureAwait(false);
+ return PairingConnectOutcome.Rejected;
+ }
+
+ if (_shutdown.IsCancellationRequested)
+ {
+ return PairingConnectOutcome.Timeout;
+ }
+
+ var firstOutcome =
+ new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token);
+ _listeners[key] = new Handle(
+ cts,
+ Task.Run(
+ () => RunAsync(key, registration.Id, credentialsJson, firstOutcome, cts.Token),
+ CancellationToken.None));
+ return await firstOutcome.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task StartAllActiveAsync(CancellationToken cancellationToken = default)
+ {
+ IReadOnlyList active;
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ active = await store.ListActiveAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ foreach (var registration in active)
+ {
+ await EnsureListenerAsync(registration.GuildId, registration.OwnerUserId, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ ///
+ public async Task StopAllAsync()
+ {
+ await _shutdown.CancelAsync().ConfigureAwait(false);
+ foreach (var key in _listeners.Keys.ToList())
+ {
+ await StopListenerAsync(key).ConfigureAwait(false);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Listener loop for owner {OwnerId} faulted.")]
+ private static partial void LogListenerLoopFaulted(ILogger logger, Exception exception, ulong ownerId);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Failed to handle pairing notification for owner {OwnerId}.")]
+ private static partial void LogNotificationFailed(ILogger logger, Exception exception, ulong ownerId);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Stored FCM credentials for owner {OwnerId} are unreadable.")]
+ private static partial void LogUnreadableCredentials(ILogger logger, Exception exception, ulong ownerId);
+
+ private async Task StopListenerAsync((ulong Guild, ulong Owner) key)
+ {
+ if (!_listeners.TryRemove(key, out var handle))
+ {
+ return;
+ }
+
+ await handle.StopAsync().ConfigureAwait(false);
+ await handle.DisposeAsync().ConfigureAwait(false);
+ }
+
+ private async Task RunAsync(
+ (ulong Guild, ulong Owner) key,
+ Guid registrationId,
+ string credentialsJson,
+ TaskCompletionSource firstOutcome,
+ CancellationToken cancellationToken)
+ {
+ var delay = _options.InitialRetryDelay;
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var listener = source.Create(
+ credentialsJson,
+ (notification, ct) => HandleNotificationAsync(key, notification, ct));
+
+ PairingConnectOutcome outcome;
+ try
+ {
+ outcome = await listener.ConnectAsync(_options.ProbeTimeout, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ await listener.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+
+ if (outcome == PairingConnectOutcome.Connected)
+ {
+ firstOutcome.TrySetResult(outcome);
+ try
+ {
+ await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Stopping.
+ }
+ finally
+ {
+ await listener.DisposeAsync().ConfigureAwait(false);
+ }
+
+ return;
+ }
+
+ await listener.DisposeAsync().ConfigureAwait(false);
+
+ if (outcome == PairingConnectOutcome.Rejected)
+ {
+ await MarkExpiredAsync(key, registrationId).ConfigureAwait(false);
+ firstOutcome.TrySetResult(outcome);
+ return;
+ }
+
+ // Timeout: signal the caller so they get the outcome immediately, then retry in background.
+ firstOutcome.TrySetResult(outcome);
+
+ await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ delay = delay < _options.MaxRetryDelay
+ ? TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, _options.MaxRetryDelay.Ticks))
+ : _options.MaxRetryDelay;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Stopping.
+ }
+#pragma warning disable CA1031 // Broad catch is intentional: a faulting listener loop must not crash the host.
+ catch (Exception ex)
+ {
+ LogListenerLoopFaulted(logger, ex, key.Owner);
+ }
+#pragma warning restore CA1031
+ finally
+ {
+ firstOutcome.TrySetResult(PairingConnectOutcome.Timeout);
+ }
+ }
+
+ private async Task HandleNotificationAsync(
+ (ulong Guild, ulong Owner) key,
+ PairingNotification notification,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var notificationScope = scopeFactory.CreateAsyncScope();
+ await using (notificationScope.ConfigureAwait(false))
+ {
+ var handler = notificationScope.ServiceProvider.GetRequiredService();
+ await handler.HandleAsync(key.Guild, key.Owner, notification, cancellationToken).ConfigureAwait(false);
+ }
+ }
+#pragma warning disable CA1031 // Broad catch is intentional: one bad notification must not kill the listener.
+ catch (Exception ex)
+ {
+ LogNotificationFailed(logger, ex, key.Owner);
+ }
+#pragma warning restore CA1031
+ }
+
+ private async Task MarkExpiredAsync((ulong Guild, ulong Owner) key, Guid registrationId)
+ {
+ var expireScope = scopeFactory.CreateAsyncScope();
+ await using (expireScope.ConfigureAwait(false))
+ {
+ var store = expireScope.ServiceProvider.GetRequiredService();
+ await store.SetStatusAsync(registrationId, FcmRegistrationStatus.Expired).ConfigureAwait(false);
+ }
+
+ await notifier.NotifyCredentialsExpiredAsync(key.Guild, key.Owner).ConfigureAwait(false);
+ }
+
+ private sealed class Handle : IAsyncDisposable
+ {
+ private readonly CancellationTokenSource _cts;
+ private readonly Task _runTask;
+
+ public Handle(CancellationTokenSource cts, Task runTask)
+ {
+ _cts = cts;
+ _runTask = runTask;
+ }
+
+ 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.Pairing/Validation/FcmCredentialValidator.cs b/src/RustPlusBot.Features.Pairing/Validation/FcmCredentialValidator.cs
new file mode 100644
index 00000000..f39af9e1
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Validation/FcmCredentialValidator.cs
@@ -0,0 +1,31 @@
+using System.Text.Json;
+
+namespace RustPlusBot.Features.Pairing.Validation;
+
+///
+/// A cheap structural guard against pasted garbage: confirms the blob is a JSON object. The authoritative
+/// check is the live connect probe (a malformed-but-objecty blob is rejected by FCM at connect time).
+///
+internal static class FcmCredentialValidator
+{
+ /// True when is non-empty and parses as a JSON object.
+ /// The pasted credentials blob.
+ /// Whether it is structurally usable.
+ public static bool IsWellFormed(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return false;
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ return document.RootElement.ValueKind == JsonValueKind.Object;
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
index 5b9cecaa..8f3c0ea0 100644
--- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
+++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
@@ -23,7 +23,8 @@ internal sealed class LocalizationCatalog
["information.servers"] = "Servers registered: {0}",
["setup.title"] = "Connect your Rust+ account",
["setup.body"] =
- "Account connection arrives in the next update. Once connected, pair a server in-game and its channels appear automatically.",
+ "Click **Connect account** below and paste your Rust+ FCM credentials. Then pair a server in-game and its channels appear automatically.",
+ ["setup.connect.button"] = "Connect account",
["settings.title"] = "Settings",
["settings.body"] = "Configure the bot for this server.",
["settings.language.label"] = "Language",
@@ -42,7 +43,9 @@ internal sealed class LocalizationCatalog
"Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.",
["information.servers"] = "Serveurs enregistres : {0}",
["setup.title"] = "Connectez votre compte Rust+",
- ["setup.body"] = "La connexion de compte arrive dans la prochaine mise a jour.",
+ ["setup.body"] =
+ "Cliquez sur **Connecter le compte** ci-dessous et collez vos identifiants FCM Rust+. Appairez ensuite un serveur en jeu et ses salons apparaitront automatiquement.",
+ ["setup.connect.button"] = "Connecter le compte",
["settings.title"] = "Parametres",
["settings.body"] = "Configurez le bot pour ce serveur.",
["settings.language.label"] = "Langue",
diff --git a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs
index 7df8ad40..8b0c2843 100644
--- a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs
+++ b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs
@@ -5,7 +5,7 @@
namespace RustPlusBot.Features.Workspace.Messages;
-/// Renders the global #setup instructions (the interactive button arrives in 1b).
+/// Renders the global #setup instructions with a Connect account button.
/// String resolution.
internal sealed class SetupMessageRenderer(ILocalizer localizer) : IMessageRenderer
{
@@ -21,6 +21,12 @@ public ValueTask RenderAsync(MessageRenderContext context, Cance
.WithDescription(localizer.Get("setup.body", context.Culture))
.WithColor(Color.Blue)
.Build();
- return ValueTask.FromResult(new MessagePayload(null, embed, null));
+ var components = new ComponentBuilder()
+ .WithButton(
+ localizer.Get("setup.connect.button", context.Culture),
+ WorkspaceComponentIds.ConnectAccount,
+ ButtonStyle.Primary)
+ .Build();
+ return ValueTask.FromResult(new MessagePayload(null, embed, components));
}
}
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs
new file mode 100644
index 00000000..5e4683a8
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs
@@ -0,0 +1,11 @@
+namespace RustPlusBot.Features.Workspace;
+
+///
+/// Public custom ids for components rendered into the workspace by Workspace but handled by other features.
+/// Feature modules reference these so the rendered control and its handler share one contract.
+///
+public static class WorkspaceComponentIds
+{
+ /// The #setup "Connect account" button; handled by the Pairing feature.
+ public const string ConnectAccount = "workspace:setup:connect";
+}
diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs
index 21357d5f..a4307e4a 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.Pairing;
using RustPlusBot.Features.Workspace;
using RustPlusBot.Host.Credentials;
using RustPlusBot.Persistence;
@@ -28,6 +29,14 @@
builder.Services.AddOptions()
.Bind(builder.Configuration.GetSection("Workspace"));
builder.Services.AddWorkspace();
+builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection("Pairing"))
+ .Validate(static o => o.ProbeTimeout > TimeSpan.Zero, "Pairing:ProbeTimeout must be positive.")
+ .Validate(static o => o.InitialRetryDelay > TimeSpan.Zero, "Pairing:InitialRetryDelay must be positive.")
+ .Validate(static o => o.MaxRetryDelay >= o.InitialRetryDelay,
+ "Pairing:MaxRetryDelay must be at least InitialRetryDelay.")
+ .ValidateOnStart();
+builder.Services.AddPairing();
var host = builder.Build();
diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
index 2a4e261e..04d6a367 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/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs
index cdb89621..de5a0572 100644
--- a/src/RustPlusBot.Persistence/BotDbContext.cs
+++ b/src/RustPlusBot.Persistence/BotDbContext.cs
@@ -24,6 +24,9 @@ public sealed class BotDbContext(DbContextOptions options) : Disco
/// Stored player credentials.
public DbSet PlayerCredentials => Set();
+ /// Per-user FCM listener registrations.
+ public DbSet FcmRegistrations => Set();
+
/// Per-server connection state.
public DbSet ConnectionStates => Set();
@@ -54,6 +57,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder
.ApplyConfiguration(new RustServerConfiguration())
.ApplyConfiguration(new PlayerCredentialConfiguration())
+ .ApplyConfiguration(new FcmRegistrationConfiguration())
.ApplyConfiguration(new ConnectionStateConfiguration())
.ApplyConfiguration(new GuildSettingsConfiguration())
.ApplyConfiguration(new PairedEntityConfiguration())
diff --git a/src/RustPlusBot.Persistence/Configurations/FcmRegistrationConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/FcmRegistrationConfiguration.cs
new file mode 100644
index 00000000..d2a1d6e4
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Configurations/FcmRegistrationConfiguration.cs
@@ -0,0 +1,19 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using RustPlusBot.Domain.Credentials;
+
+namespace RustPlusBot.Persistence.Configurations;
+
+internal sealed class FcmRegistrationConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.HasKey(r => r.Id);
+ builder.HasIndex(r => new
+ {
+ r.GuildId, r.OwnerUserId
+ }).IsUnique();
+ builder.Property(r => r.ProtectedFcmCredentials).IsRequired();
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Configurations/PlayerCredentialConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/PlayerCredentialConfiguration.cs
index 168ee0cd..51dfc805 100644
--- a/src/RustPlusBot.Persistence/Configurations/PlayerCredentialConfiguration.cs
+++ b/src/RustPlusBot.Persistence/Configurations/PlayerCredentialConfiguration.cs
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RustPlusBot.Domain.Credentials;
+using RustPlusBot.Domain.Servers;
namespace RustPlusBot.Persistence.Configurations;
@@ -12,9 +13,14 @@ public void Configure(EntityTypeBuilder builder)
builder.HasKey(c => c.Id);
builder.HasIndex(c => new
{
- c.GuildId, c.RustServerId
- });
+ c.GuildId, c.RustServerId, c.OwnerUserId
+ }).IsUnique();
builder.Property(c => c.ProtectedPlayerToken).IsRequired();
- builder.Property(c => c.ProtectedFcmCredentials).IsRequired();
+
+ // Removing a RustServer cascades to its credentials so orphaned secrets cannot linger.
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(c => c.RustServerId)
+ .OnDelete(DeleteBehavior.Cascade);
}
}
diff --git a/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs
index 5335b4d7..6925d714 100644
--- a/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs
+++ b/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs
@@ -13,5 +13,9 @@ public void Configure(EntityTypeBuilder builder)
builder.Property(s => s.Name).IsRequired().HasMaxLength(128);
builder.Property(s => s.Ip).IsRequired().HasMaxLength(255);
builder.HasIndex(s => s.GuildId);
+ builder.HasIndex(s => new
+ {
+ s.GuildId, s.Ip, s.Port
+ }).IsUnique();
}
}
diff --git a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs
index 10789c57..6a02f12c 100644
--- a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs
+++ b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs
@@ -10,10 +10,34 @@ namespace RustPlusBot.Persistence.Credentials;
public sealed class CredentialStore(BotDbContext context, ICredentialProtector protector) : ICredentialStore
{
///
- public async Task StoreAsync(StoreCredentialRequest request, CancellationToken cancellationToken = default)
+ public async Task UpsertFromPairingAsync(
+ StoreCredentialRequest request,
+ bool markActive,
+ CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
+ var existing = await context.PlayerCredentials
+ .SingleOrDefaultAsync(
+ c => c.GuildId == request.GuildId
+ && c.RustServerId == request.RustServerId
+ && c.OwnerUserId == request.OwnerUserId,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ if (existing is not null)
+ {
+ existing.SteamId = request.SteamId;
+ existing.ProtectedPlayerToken = protector.Protect(request.PlayerToken);
+ if (existing.Status == CredentialStatus.Invalid)
+ {
+ existing.Status = CredentialStatus.Standby;
+ }
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return existing.Id;
+ }
+
var credential = new PlayerCredential
{
GuildId = request.GuildId,
@@ -21,8 +45,7 @@ public async Task StoreAsync(StoreCredentialRequest request, CancellationT
OwnerUserId = request.OwnerUserId,
SteamId = request.SteamId,
ProtectedPlayerToken = protector.Protect(request.PlayerToken),
- ProtectedFcmCredentials = protector.Protect(request.FcmCredentialsJson),
- Status = CredentialStatus.Standby,
+ Status = markActive ? CredentialStatus.Active : CredentialStatus.Standby,
};
context.PlayerCredentials.Add(credential);
diff --git a/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs b/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs
new file mode 100644
index 00000000..5b4fec39
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs
@@ -0,0 +1,100 @@
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Credentials;
+
+namespace RustPlusBot.Persistence.Credentials;
+
+/// EF-backed that protects credentials before persisting.
+/// The bot database context.
+/// Protects credential material before it is written.
+/// Supplies the update timestamp.
+public sealed class FcmRegistrationStore(BotDbContext context, ICredentialProtector protector, IClock clock)
+ : IFcmRegistrationStore
+{
+ ///
+ public async Task UpsertAsync(
+ ulong guildId,
+ ulong ownerUserId,
+ string fcmCredentialsJson,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(fcmCredentialsJson);
+
+ var existing = await context.FcmRegistrations
+ .SingleOrDefaultAsync(r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (existing is not null)
+ {
+ existing.ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson);
+ existing.Status = FcmRegistrationStatus.Active;
+ existing.UpdatedAt = clock.UtcNow;
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return existing.Id;
+ }
+
+ var registration = new FcmRegistration
+ {
+ GuildId = guildId,
+ OwnerUserId = ownerUserId,
+ ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson),
+ Status = FcmRegistrationStatus.Active,
+ UpdatedAt = clock.UtcNow,
+ };
+
+ context.FcmRegistrations.Add(registration);
+ try
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return registration.Id;
+ }
+ catch (DbUpdateException)
+ {
+ // A concurrent submission for the same (guild, owner) won the unique-index race; update the winner.
+ context.Entry(registration).State = EntityState.Detached;
+ var winner = await context.FcmRegistrations
+ .SingleAsync(r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, cancellationToken)
+ .ConfigureAwait(false);
+ winner.ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson);
+ winner.Status = FcmRegistrationStatus.Active;
+ winner.UpdatedAt = clock.UtcNow;
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return winner.Id;
+ }
+ }
+
+ ///
+ public async Task> ListActiveAsync(CancellationToken cancellationToken = default) =>
+ await context.FcmRegistrations
+ .Where(r => r.Status == FcmRegistrationStatus.Active)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ ///
+ public async Task SetStatusAsync(
+ Guid id,
+ FcmRegistrationStatus status,
+ CancellationToken cancellationToken = default)
+ {
+ var registration = await context.FcmRegistrations
+ .SingleOrDefaultAsync(r => r.Id == id, cancellationToken)
+ .ConfigureAwait(false);
+ if (registration is null)
+ {
+ return;
+ }
+
+ registration.Status = status;
+ registration.UpdatedAt = clock.UtcNow;
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public Task GetAsync(
+ ulong guildId,
+ ulong ownerUserId,
+ CancellationToken cancellationToken = default) =>
+ context.FcmRegistrations
+ .SingleOrDefaultAsync(r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, cancellationToken);
+}
diff --git a/src/RustPlusBot.Persistence/Credentials/IFcmRegistrationStore.cs b/src/RustPlusBot.Persistence/Credentials/IFcmRegistrationStore.cs
new file mode 100644
index 00000000..4c918e53
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Credentials/IFcmRegistrationStore.cs
@@ -0,0 +1,42 @@
+using RustPlusBot.Domain.Credentials;
+
+namespace RustPlusBot.Persistence.Credentials;
+
+///
+/// Persists per-user FCM listener registrations. Credentials are protected at rest. Lives in the
+/// persistence layer (not Abstractions) because it returns the Domain
+/// type, and Abstractions has no Domain reference.
+///
+public interface IFcmRegistrationStore
+{
+ /// Inserts or refreshes the registration for (guild, owner), setting it Active. Returns its id.
+ /// Owning Discord guild snowflake.
+ /// The Discord user connecting credentials.
+ /// The plaintext FCM credentials JSON (protected before storage).
+ /// A cancellation token.
+ /// The registration's id.
+ Task UpsertAsync(
+ ulong guildId,
+ ulong ownerUserId,
+ string fcmCredentialsJson,
+ CancellationToken cancellationToken = default);
+
+ /// Lists every Active registration across all guilds (used at startup).
+ /// A cancellation token.
+ /// The active registrations.
+ Task> ListActiveAsync(CancellationToken cancellationToken = default);
+
+ /// Sets a registration's status.
+ /// The registration id.
+ /// The new status.
+ /// A cancellation token.
+ /// A task that completes when the status has been persisted.
+ Task SetStatusAsync(Guid id, FcmRegistrationStatus status, CancellationToken cancellationToken = default);
+
+ /// Gets the registration for (guild, owner), or null.
+ /// Owning Discord guild snowflake.
+ /// The Discord user.
+ /// A cancellation token.
+ /// The registration, or null.
+ Task GetAsync(ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default);
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.Designer.cs
new file mode 100644
index 00000000..68a0f4be
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.Designer.cs
@@ -0,0 +1,463 @@
+//
+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("20260615085447_PairingCredentials")]
+ partial class PairingCredentials
+ {
+ ///
+ 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("IsHealthy")
+ .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/20260615085447_PairingCredentials.cs b/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.cs
new file mode 100644
index 00000000..f7ba1456
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.cs
@@ -0,0 +1,105 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ ///
+ public partial class PairingCredentials : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_PlayerCredentials_GuildId_RustServerId",
+ table: "PlayerCredentials");
+
+ migrationBuilder.DropColumn(
+ name: "ProtectedFcmCredentials",
+ table: "PlayerCredentials");
+
+ migrationBuilder.CreateTable(
+ name: "FcmRegistrations",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ GuildId = table.Column(type: "INTEGER", nullable: false),
+ OwnerUserId = table.Column(type: "INTEGER", nullable: false),
+ ProtectedFcmCredentials = table.Column(type: "TEXT", nullable: false),
+ Status = table.Column(type: "INTEGER", nullable: false),
+ UpdatedAt = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_FcmRegistrations", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RustServers_GuildId_Ip_Port",
+ table: "RustServers",
+ columns: new[] { "GuildId", "Ip", "Port" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PlayerCredentials_GuildId_RustServerId_OwnerUserId",
+ table: "PlayerCredentials",
+ columns: new[] { "GuildId", "RustServerId", "OwnerUserId" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PlayerCredentials_RustServerId",
+ table: "PlayerCredentials",
+ column: "RustServerId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_FcmRegistrations_GuildId_OwnerUserId",
+ table: "FcmRegistrations",
+ columns: new[] { "GuildId", "OwnerUserId" },
+ unique: true);
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_PlayerCredentials_RustServers_RustServerId",
+ table: "PlayerCredentials",
+ column: "RustServerId",
+ principalTable: "RustServers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_PlayerCredentials_RustServers_RustServerId",
+ table: "PlayerCredentials");
+
+ migrationBuilder.DropTable(
+ name: "FcmRegistrations");
+
+ migrationBuilder.DropIndex(
+ name: "IX_RustServers_GuildId_Ip_Port",
+ table: "RustServers");
+
+ migrationBuilder.DropIndex(
+ name: "IX_PlayerCredentials_GuildId_RustServerId_OwnerUserId",
+ table: "PlayerCredentials");
+
+ migrationBuilder.DropIndex(
+ name: "IX_PlayerCredentials_RustServerId",
+ table: "PlayerCredentials");
+
+ migrationBuilder.AddColumn(
+ name: "ProtectedFcmCredentials",
+ table: "PlayerCredentials",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: "");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PlayerCredentials_GuildId_RustServerId",
+ table: "PlayerCredentials",
+ columns: new[] { "GuildId", "RustServerId" });
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
index 215fe39b..4863537a 100644
--- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
+++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
@@ -145,7 +145,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("ConnectionStates");
});
- modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b =>
{
b.Property("Id")
.ValueGeneratedOnAdd()
@@ -161,6 +161,32 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.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");
@@ -176,7 +202,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasKey("Id");
- b.HasIndex("GuildId", "RustServerId");
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "OwnerUserId")
+ .IsUnique();
b.ToTable("PlayerCredentials");
});
@@ -279,6 +308,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("GuildId");
+ b.HasIndex("GuildId", "Ip", "Port")
+ .IsUnique();
+
b.ToTable("RustServers");
});
@@ -390,6 +422,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.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)
diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
index 5ae42e94..efabc3ec 100644
--- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
@@ -29,6 +29,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
return services;
diff --git a/src/RustPlusBot.Persistence/Servers/IServerService.cs b/src/RustPlusBot.Persistence/Servers/IServerService.cs
index 57f77484..81490e5d 100644
--- a/src/RustPlusBot.Persistence/Servers/IServerService.cs
+++ b/src/RustPlusBot.Persistence/Servers/IServerService.cs
@@ -39,4 +39,23 @@ Task AddAsync(ulong guildId,
/// A cancellation token.
/// The server, or null if not found in this guild.
Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the server matching (guild, ip, port), creating it if none exists. Handles the
+ /// concurrent-create race (two pairings for the same new server) by re-reading the winner.
+ ///
+ /// Owning Discord guild snowflake.
+ /// The Discord user whose pairing created it (only used on create).
+ /// Display name (only used on create).
+ /// Server host or ip.
+ /// Rust+ app port.
+ /// A cancellation token.
+ /// The resolved server and whether it was newly created.
+ Task<(RustServer Server, bool Created)> ResolveOrCreateByEndpointAsync(
+ ulong guildId,
+ ulong addedByUserId,
+ string name,
+ string ip,
+ int port,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/RustPlusBot.Persistence/Servers/ServerService.cs b/src/RustPlusBot.Persistence/Servers/ServerService.cs
index a20c27b5..6c23ae48 100644
--- a/src/RustPlusBot.Persistence/Servers/ServerService.cs
+++ b/src/RustPlusBot.Persistence/Servers/ServerService.cs
@@ -63,4 +63,46 @@ public async Task RemoveAsync(
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return true;
}
+
+ ///
+ public async Task<(RustServer Server, bool Created)> ResolveOrCreateByEndpointAsync(
+ ulong guildId,
+ ulong addedByUserId,
+ string name,
+ string ip,
+ int port,
+ CancellationToken cancellationToken = default)
+ {
+ var existing = await context.RustServers
+ .SingleOrDefaultAsync(s => s.GuildId == guildId && s.Ip == ip && s.Port == port, cancellationToken)
+ .ConfigureAwait(false);
+ if (existing is not null)
+ {
+ return (existing, false);
+ }
+
+ var server = new RustServer
+ {
+ GuildId = guildId,
+ AddedByUserId = addedByUserId,
+ Name = name,
+ Ip = ip,
+ Port = port,
+ };
+ context.RustServers.Add(server);
+ try
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return (server, true);
+ }
+ catch (DbUpdateException)
+ {
+ // Another pairing created the same endpoint between the read and the write; re-read the winner.
+ context.Entry(server).State = EntityState.Detached;
+ var winner = await context.RustServers
+ .SingleAsync(s => s.GuildId == guildId && s.Ip == ip && s.Port == port, cancellationToken)
+ .ConfigureAwait(false);
+ return (winner, false);
+ }
+ }
}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs
new file mode 100644
index 00000000..19aa4b7b
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs
@@ -0,0 +1,51 @@
+using System.Collections.Concurrent;
+using RustPlusBot.Features.Pairing.Listening;
+
+namespace RustPlusBot.Features.Pairing.Tests.Fakes;
+
+/// A scripted : each created listener returns the next queued outcome.
+internal sealed class FakePairingSource : IPairingSource
+{
+ private readonly ConcurrentQueue _outcomes = new();
+
+ private int _createCount;
+
+ /// How many listeners have been created.
+ public int CreateCount => Volatile.Read(ref _createCount);
+
+ /// The last notification callback registered by the supervisor.
+ public Func? LastCallback { get; private set; }
+
+ /// Signaled when the first Connected outcome fires.
+ public TaskCompletionSource ConnectedSignal { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ ///
+ public IPairingListener Create(
+ string fcmCredentialsJson,
+ Func onNotification)
+ {
+ Interlocked.Increment(ref _createCount);
+ LastCallback = onNotification;
+ var outcome = _outcomes.TryDequeue(out var next) ? next : PairingConnectOutcome.Connected;
+ return new FakeListener(outcome, () => ConnectedSignal.TrySetResult());
+ }
+
+ /// Enqueues an outcome to be returned by the next listener created.
+ /// The outcome the next listener will return from ConnectAsync.
+ public void EnqueueOutcome(PairingConnectOutcome outcome) => _outcomes.Enqueue(outcome);
+
+ private sealed class FakeListener(PairingConnectOutcome outcome, Action onConnected) : IPairingListener
+ {
+ public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ if (outcome == PairingConnectOutcome.Connected)
+ {
+ onConnected();
+ }
+
+ return Task.FromResult(outcome);
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs
new file mode 100644
index 00000000..18a8e64c
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs
@@ -0,0 +1,20 @@
+using System.Collections.Concurrent;
+using RustPlusBot.Features.Pairing.Notifications;
+
+namespace RustPlusBot.Features.Pairing.Tests.Fakes;
+
+/// Records expiry notifications instead of DMing.
+internal sealed class RecordingOwnerNotifier : IOwnerNotifier
+{
+ /// All (guild, owner) pairs that received an expiry notification.
+ public ConcurrentBag<(ulong Guild, ulong Owner)> Notified { get; } = [];
+
+ ///
+ public Task NotifyCredentialsExpiredAsync(ulong guildId,
+ ulong ownerUserId,
+ CancellationToken cancellationToken = default)
+ {
+ Notified.Add((guildId, ownerUserId));
+ return Task.CompletedTask;
+ }
+}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/FcmCredentialValidatorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/FcmCredentialValidatorTests.cs
new file mode 100644
index 00000000..ec649dc6
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/FcmCredentialValidatorTests.cs
@@ -0,0 +1,22 @@
+using RustPlusBot.Features.Pairing.Validation;
+
+namespace RustPlusBot.Features.Pairing.Tests;
+
+public sealed class FcmCredentialValidatorTests
+{
+ [Theory]
+ [InlineData("{\"fcm\":{\"token\":\"abc\"}}")]
+ [InlineData(" {\"a\":1} ")]
+ public void IsWellFormed_True_ForJsonObject(string json) =>
+ Assert.True(FcmCredentialValidator.IsWellFormed(json));
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ [InlineData("not json")]
+ [InlineData("[1,2,3]")]
+ [InlineData("42")]
+ public void IsWellFormed_False_ForNonObjectOrGarbage(string? json) =>
+ Assert.False(FcmCredentialValidator.IsWellFormed(json));
+}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs
new file mode 100644
index 00000000..85fbda89
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs
@@ -0,0 +1,87 @@
+using Microsoft.EntityFrameworkCore;
+using NSubstitute;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Domain.Credentials;
+using RustPlusBot.Features.Pairing.Listening;
+using RustPlusBot.Features.Pairing.Pairing;
+using RustPlusBot.Persistence;
+using RustPlusBot.Persistence.Credentials;
+using RustPlusBot.Persistence.Servers;
+
+namespace RustPlusBot.Features.Pairing.Tests;
+
+public sealed class PairingHandlerTests
+{
+ private static ICredentialProtector PassThrough()
+ {
+ var p = Substitute.For();
+ p.Protect(Arg.Any()).Returns(c => c.Arg());
+ return p;
+ }
+
+ private static PairingHandler CreateHandler(BotDbContext context, IEventBus bus) =>
+ new(new ServerService(context), new CredentialStore(context, PassThrough()), bus,
+ Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
+
+ private static PairingNotification ServerPairing(string ip = "1.2.3.4", int port = 28015, ulong steam = 7UL) =>
+ new(PairingKind.Server, "Rustopia", ip, port, steam, "ptoken");
+
+ [Fact]
+ public async Task ServerPairing_CreatesServerCredentialAndFiresEventOnce()
+ {
+ var (context, connection) = TestDb.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var bus = Substitute.For();
+ var handler = CreateHandler(context, bus);
+
+ await handler.HandleAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ var server = await context.RustServers.SingleAsync();
+ Assert.Equal("Rustopia", server.Name);
+ var credential = await context.PlayerCredentials.SingleAsync();
+ Assert.Equal(server.Id, credential.RustServerId);
+ Assert.Equal(CredentialStatus.Active, credential.Status);
+ await bus.Received(1).PublishAsync(
+ Arg.Is(e => e.GuildId == 10UL && e.ServerId == server.Id),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task SecondOwnerSameServer_AddsStandbyCredentialNoEvent()
+ {
+ var (context, connection) = TestDb.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var bus = Substitute.For();
+ var handler = CreateHandler(context, bus);
+
+ await handler.HandleAsync(10UL, 1UL, ServerPairing(steam: 1UL), CancellationToken.None);
+ bus.ClearReceivedCalls();
+ await handler.HandleAsync(10UL, 2UL, ServerPairing(steam: 2UL), CancellationToken.None);
+
+ Assert.Single(await context.RustServers.ToListAsync());
+ Assert.Equal(2, await context.PlayerCredentials.CountAsync());
+ var second = await context.PlayerCredentials.SingleAsync(c => c.OwnerUserId == 2UL);
+ Assert.Equal(CredentialStatus.Standby, second.Status);
+ await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task EntityPairing_IsIgnored()
+ {
+ var (context, connection) = TestDb.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var bus = Substitute.For();
+ var handler = CreateHandler(context, bus);
+
+ await handler.HandleAsync(10UL, 1UL,
+ new PairingNotification(PairingKind.Entity, "x", "1.2.3.4", 28015, 1UL, "t"), CancellationToken.None);
+
+ Assert.Empty(await context.RustServers.ToListAsync());
+ Assert.Empty(await context.PlayerCredentials.ToListAsync());
+ await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any());
+ }
+}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs
new file mode 100644
index 00000000..891283cf
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs
@@ -0,0 +1,38 @@
+using Discord.WebSocket;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Features.Pairing;
+using RustPlusBot.Features.Pairing.Pairing;
+using RustPlusBot.Features.Pairing.Supervisor;
+using RustPlusBot.Persistence;
+
+namespace RustPlusBot.Features.Pairing.Tests;
+
+public sealed class PairingRegistrationTests
+{
+ [Fact]
+ public async Task Services_Resolve()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(new DiscordSocketClient());
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(Substitute.For());
+ services.AddLogging();
+ services.AddBotPersistence("DataSource=:memory:");
+ services.AddOptions();
+ services.AddPairing();
+
+ 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.Pairing.Tests/PairingSupervisorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs
new file mode 100644
index 00000000..334aa34a
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs
@@ -0,0 +1,187 @@
+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.Domain.Credentials;
+using RustPlusBot.Features.Pairing.Listening;
+using RustPlusBot.Features.Pairing.Notifications;
+using RustPlusBot.Features.Pairing.Pairing;
+using RustPlusBot.Features.Pairing.Supervisor;
+using RustPlusBot.Features.Pairing.Tests.Fakes;
+using RustPlusBot.Persistence;
+using RustPlusBot.Persistence.Credentials;
+
+namespace RustPlusBot.Features.Pairing.Tests;
+
+public sealed class PairingSupervisorTests
+{
+ private static Harness CreateHarness(FakePairingSource source, PairingOptions? options = null)
+ {
+ var protector = Substitute.For();
+ protector.Protect(Arg.Any()).Returns(c => c.Arg());
+ protector.Unprotect(Arg.Any()).Returns(c => c.Arg());
+
+ var clock = Substitute.For();
+ clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
+
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(clock);
+ services.AddSingleton(protector);
+ services.AddSingleton();
+
+ // Keep one open in-memory SQLite connection (singleton) and give each scope its OWN context.
+ var (seed, connection) = TestDb.Create();
+ seed.Dispose();
+ services.AddSingleton(connection);
+ services.AddScoped(sp => new BotDbContext(
+ new DbContextOptionsBuilder().UseSqlite(sp.GetRequiredService()).Options));
+ services.AddScoped();
+ services.AddScoped(_ => Substitute.For());
+
+ var notifier = new RecordingOwnerNotifier();
+ services.AddSingleton(notifier);
+ services.AddSingleton(source);
+ services.AddSingleton(Options.Create(options ?? new PairingOptions
+ {
+ ProbeTimeout = TimeSpan.FromSeconds(1),
+ InitialRetryDelay = TimeSpan.FromMilliseconds(5),
+ MaxRetryDelay = TimeSpan.FromMilliseconds(20),
+ }));
+ services.AddSingleton();
+
+ var provider = services.BuildServiceProvider();
+ return new Harness
+ {
+ Provider = provider,
+ Source = source,
+ Notifier = notifier,
+ Supervisor = provider.GetRequiredService(),
+ };
+ }
+
+ private static async Task SeedRegistrationAsync(ServiceProvider provider, ulong guild, ulong owner)
+ {
+ using var scope = provider.CreateScope();
+ var store = scope.ServiceProvider.GetRequiredService();
+ return await store.UpsertAsync(guild, owner, "{}");
+ }
+
+ [Fact]
+ public async Task EnsureListener_Connected_ReturnsConnectedAndStaysActive()
+ {
+ var source = new FakePairingSource();
+ source.EnqueueOutcome(PairingConnectOutcome.Connected);
+ await using var h = CreateHarness(source);
+ await SeedRegistrationAsync(h.Provider, 10UL, 99UL);
+
+ var outcome = await h.Supervisor.EnsureListenerAsync(10UL, 99UL);
+
+ Assert.Equal(PairingConnectOutcome.Connected, outcome);
+ Assert.Empty(h.Notifier.Notified);
+ using var scope = h.Provider.CreateScope();
+ var reg = await scope.ServiceProvider.GetRequiredService().GetAsync(10UL, 99UL);
+ Assert.Equal(FcmRegistrationStatus.Active, reg!.Status);
+ }
+
+ [Fact]
+ public async Task EnsureListener_Rejected_MarksExpiredAndNotifies()
+ {
+ var source = new FakePairingSource();
+ source.EnqueueOutcome(PairingConnectOutcome.Rejected);
+ await using var h = CreateHarness(source);
+ await SeedRegistrationAsync(h.Provider, 10UL, 99UL);
+
+ var outcome = await h.Supervisor.EnsureListenerAsync(10UL, 99UL);
+
+ Assert.Equal(PairingConnectOutcome.Rejected, outcome);
+ using var scope = h.Provider.CreateScope();
+ var reg = await scope.ServiceProvider.GetRequiredService().GetAsync(10UL, 99UL);
+ Assert.Equal(FcmRegistrationStatus.Expired, reg!.Status);
+ Assert.Contains((10UL, 99UL), h.Notifier.Notified);
+ }
+
+ [Fact]
+ public async Task EnsureListener_TimeoutThenConnected_RetriesAndStaysActive()
+ {
+ var source = new FakePairingSource();
+ source.EnqueueOutcome(PairingConnectOutcome.Timeout);
+ source.EnqueueOutcome(PairingConnectOutcome.Connected);
+ await using var h = CreateHarness(source);
+ await SeedRegistrationAsync(h.Provider, 10UL, 99UL);
+
+ var outcome = await h.Supervisor.EnsureListenerAsync(10UL, 99UL);
+ Assert.Equal(PairingConnectOutcome.Timeout, outcome);
+
+ await h.Source.ConnectedSignal.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.True(h.Source.CreateCount >= 2);
+ using var scope = h.Provider.CreateScope();
+ var reg = await scope.ServiceProvider.GetRequiredService().GetAsync(10UL, 99UL);
+ Assert.Equal(FcmRegistrationStatus.Active, reg!.Status);
+ }
+
+ [Fact]
+ public async Task ConnectedListener_DeliversNotificationWithoutFaulting()
+ {
+ var source = new FakePairingSource();
+ source.EnqueueOutcome(PairingConnectOutcome.Connected);
+ await using var h = CreateHarness(source);
+ await SeedRegistrationAsync(h.Provider, 10UL, 99UL);
+ await h.Supervisor.EnsureListenerAsync(10UL, 99UL);
+
+ var note = new PairingNotification(PairingKind.Server, "S", "1.2.3.4", 28015, 7UL, "tok");
+ await h.Source.LastCallback!(note, CancellationToken.None);
+
+ using var scope = h.Provider.CreateScope();
+ var reg = await scope.ServiceProvider.GetRequiredService().GetAsync(10UL, 99UL);
+ Assert.Equal(FcmRegistrationStatus.Active, reg!.Status);
+ }
+
+ [Fact]
+ public async Task StartAllActive_StartsAListenerPerActiveRegistration()
+ {
+ var source = new FakePairingSource();
+ source.EnqueueOutcome(PairingConnectOutcome.Connected);
+ source.EnqueueOutcome(PairingConnectOutcome.Connected);
+ await using var h = CreateHarness(source);
+ await SeedRegistrationAsync(h.Provider, 10UL, 1UL);
+ await SeedRegistrationAsync(h.Provider, 10UL, 2UL);
+
+ await h.Supervisor.StartAllActiveAsync();
+
+ Assert.Equal(2, h.Source.CreateCount);
+ }
+
+ [Fact]
+ public async Task EnsureListener_CalledTwiceForSameOwner_RestartsListener()
+ {
+ var source = new FakePairingSource();
+ source.EnqueueOutcome(PairingConnectOutcome.Connected);
+ source.EnqueueOutcome(PairingConnectOutcome.Connected);
+ await using var h = CreateHarness(source);
+ await SeedRegistrationAsync(h.Provider, 10UL, 99UL);
+
+ await h.Supervisor.EnsureListenerAsync(10UL, 99UL);
+ await h.Supervisor.EnsureListenerAsync(10UL, 99UL);
+
+ Assert.Equal(2, h.Source.CreateCount);
+ }
+
+ private sealed class Harness : IAsyncDisposable
+ {
+ public required ServiceProvider Provider { get; init; }
+ public required FakePairingSource Source { get; init; }
+ public required RecordingOwnerNotifier Notifier { get; init; }
+ public required PairingSupervisor Supervisor { get; init; }
+
+ public async ValueTask DisposeAsync()
+ {
+ await Supervisor.StopAllAsync();
+ await Provider.DisposeAsync();
+ }
+ }
+}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj b/tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj
new file mode 100644
index 00000000..8e789ae2
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/RustPlusFcmPairingSourceTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/RustPlusFcmPairingSourceTests.cs
new file mode 100644
index 00000000..126c2417
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/RustPlusFcmPairingSourceTests.cs
@@ -0,0 +1,20 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using RustPlusBot.Features.Pairing.Listening;
+
+namespace RustPlusBot.Features.Pairing.Tests;
+
+public sealed class RustPlusFcmPairingSourceTests
+{
+ [Fact]
+ public async Task Create_DoesNotThrow_AndConnectReturnsRejected_ForUnparseableCredentials()
+ {
+ var source = new RustPlusFcmPairingSource(NullLogger.Instance);
+
+ // "null" deserializes to a null Credentials, which the listener ctor rejects — Create must not throw.
+ var listener = source.Create("null", (_, _) => Task.CompletedTask);
+ var outcome = await listener.ConnectAsync(TimeSpan.FromSeconds(1), CancellationToken.None);
+
+ Assert.Equal(PairingConnectOutcome.Rejected, outcome);
+ await listener.DisposeAsync();
+ }
+}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs b/tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs
new file mode 100644
index 00000000..8343b303
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs
@@ -0,0 +1,19 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Persistence;
+
+namespace RustPlusBot.Features.Pairing.Tests;
+
+/// Creates a BotDbContext over a private in-memory SQLite connection kept open for the test.
+internal static class TestDb
+{
+ public static (BotDbContext Context, SqliteConnection Connection) Create()
+ {
+ var connection = new SqliteConnection("DataSource=:memory:");
+ connection.Open();
+ var options = new DbContextOptionsBuilder().UseSqlite(connection).Options;
+ var context = new BotDbContext(options);
+ context.Database.Migrate();
+ return (context, connection);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs
index 325626e7..37e17cd3 100644
--- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs
@@ -75,4 +75,17 @@ public async Task ServerInfo_TitleIsServerName_AndEndpointShown()
Assert.Equal("Rustopia EU", payload.Embed!.Title);
Assert.Contains("1.2.3.4", payload.Embed.Description, StringComparison.Ordinal);
}
+
+ [Fact]
+ public async Task Setup_HasConnectAccountButton()
+ {
+ var renderer = new SetupMessageRenderer(Loc);
+
+ var payload = await renderer.RenderAsync(Global, default);
+
+ Assert.NotNull(payload.Components);
+ var buttons = payload.Components!.Components.OfType()
+ .SelectMany(r => r.Components).OfType();
+ Assert.Contains(buttons, b => b.CustomId == "workspace:setup:connect");
+ }
}
diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs
index 84841094..a5f4e78e 100644
--- a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs
+++ b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs
@@ -2,6 +2,7 @@
using NSubstitute;
using RustPlusBot.Abstractions.Credentials;
using RustPlusBot.Domain.Credentials;
+using RustPlusBot.Domain.Servers;
using RustPlusBot.Persistence.Credentials;
namespace RustPlusBot.Persistence.Tests.Credentials;
@@ -15,51 +16,100 @@ private static ICredentialProtector PassThroughProtector()
return protector;
}
+ private static async Task SeedServerAsync(BotDbContext context, ulong guildId, int port = 28015)
+ {
+ var server = new RustServer
+ {
+ GuildId = guildId, Name = "S", Ip = "1.1.1.1", Port = port
+ };
+ context.RustServers.Add(server);
+ await context.SaveChangesAsync();
+ return server.Id;
+ }
+
[Fact]
- public async Task StoreAsync_ProtectsTokenAndPersistsAsStandby()
+ public async Task UpsertFromPairing_FirstCredentialForServer_IsActiveAndProtected()
{
var (context, connection) = SqliteContextFixture.Create();
await using var _ = context;
await using var __ = connection;
-
+ var serverId = await SeedServerAsync(context, 10UL);
var protector = PassThroughProtector();
var store = new CredentialStore(context, protector);
- var serverId = Guid.NewGuid();
- var id = await store.StoreAsync(new StoreCredentialRequest(
- GuildId: 10UL,
- RustServerId: serverId,
- OwnerUserId: 99UL,
- SteamId: 76561198000000000UL,
- PlayerToken: "raw-token",
- FcmCredentialsJson: "{}"));
+ var id = await store.UpsertFromPairingAsync(
+ new StoreCredentialRequest(10UL, serverId, 99UL, 76561198000000000UL, "raw-token"),
+ markActive: true);
var saved = await context.PlayerCredentials.SingleAsync();
Assert.Equal(id, saved.Id);
Assert.Equal("enc:raw-token", saved.ProtectedPlayerToken);
- Assert.Equal("enc:{}", saved.ProtectedFcmCredentials);
- Assert.Equal(CredentialStatus.Standby, saved.Status);
-
- // Both secret fields must be protected before persistence (security invariant).
+ Assert.Equal(CredentialStatus.Active, saved.Status);
protector.Received(1).Protect("raw-token");
- protector.Received(1).Protect("{}");
}
[Fact]
- public async Task CountForServerAsync_CountsOnlyMatchingGuildAndServer()
+ public async Task UpsertFromPairing_SecondOwnerForServer_IsStandby()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var serverId = await SeedServerAsync(context, 10UL);
+ var store = new CredentialStore(context, PassThroughProtector());
+
+ await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverId, 1UL, 1UL, "t1"),
+ markActive: true);
+ await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverId, 2UL, 2UL, "t2"),
+ markActive: false);
+
+ var second = await context.PlayerCredentials.SingleAsync(c => c.OwnerUserId == 2UL);
+ Assert.Equal(CredentialStatus.Standby, second.Status);
+ Assert.Equal(2, await store.CountForServerAsync(10UL, serverId));
+ }
+
+ [Fact]
+ public async Task UpsertFromPairing_SameOwnerAgain_RefreshesTokenAndResetsInvalid()
{
var (context, connection) = SqliteContextFixture.Create();
await using var _ = context;
await using var __ = connection;
+ var serverId = await SeedServerAsync(context, 10UL);
+ var store = new CredentialStore(context, PassThroughProtector());
+
+ var id = await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverId, 1UL, 1UL, "old"),
+ markActive: true);
+ var row = await context.PlayerCredentials.SingleAsync();
+ row.Status = CredentialStatus.Invalid;
+ await context.SaveChangesAsync();
+
+ var again = await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverId, 1UL, 1UL, "new"),
+ markActive: false);
+
+ Assert.Equal(id, again);
+ var saved = await context.PlayerCredentials.SingleAsync();
+ Assert.Equal("enc:new", saved.ProtectedPlayerToken);
+ Assert.Equal(CredentialStatus.Standby, saved.Status);
+ }
+ [Fact]
+ public async Task CountForServer_CountsOnlyMatchingGuildAndServer()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var serverA = await SeedServerAsync(context, 10UL);
+ var serverB = await SeedServerAsync(context, 10UL, port: 28016);
+ var serverAGuild20 = await SeedServerAsync(context, 20UL);
var store = new CredentialStore(context, PassThroughProtector());
- var serverA = Guid.NewGuid();
- var serverB = Guid.NewGuid();
- await store.StoreAsync(new StoreCredentialRequest(10UL, serverA, 1UL, 1UL, "t1", "{}"));
- await store.StoreAsync(new StoreCredentialRequest(10UL, serverA, 2UL, 2UL, "t2", "{}"));
- await store.StoreAsync(new StoreCredentialRequest(10UL, serverB, 3UL, 3UL, "t3", "{}"));
- await store.StoreAsync(new StoreCredentialRequest(20UL, serverA, 4UL, 4UL, "t4", "{}"));
+ await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 1UL, 1UL, "t1"),
+ markActive: false);
+ await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 2UL, 2UL, "t2"),
+ markActive: false);
+ await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverB, 3UL, 3UL, "t3"),
+ markActive: false);
+ await store.UpsertFromPairingAsync(new StoreCredentialRequest(20UL, serverAGuild20, 4UL, 4UL, "t4"),
+ markActive: false);
Assert.Equal(2, await store.CountForServerAsync(10UL, serverA));
}
diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs
new file mode 100644
index 00000000..8b7bcf95
--- /dev/null
+++ b/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs
@@ -0,0 +1,110 @@
+using Microsoft.EntityFrameworkCore;
+using NSubstitute;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Credentials;
+using RustPlusBot.Persistence.Credentials;
+
+namespace RustPlusBot.Persistence.Tests.Credentials;
+
+public sealed class FcmRegistrationStoreTests
+{
+ private static ICredentialProtector PassThroughProtector()
+ {
+ var protector = Substitute.For();
+ protector.Protect(Arg.Any()).Returns(call => "enc:" + call.Arg());
+ return protector;
+ }
+
+ private static IClock FixedClock()
+ {
+ var clock = Substitute.For();
+ clock.UtcNow.Returns(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero));
+ return clock;
+ }
+
+ [Fact]
+ public async Task Upsert_StoresProtectedAndActive()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock());
+
+ var id = await store.UpsertAsync(10UL, 99UL, "{\"a\":1}");
+
+ var saved = await context.FcmRegistrations.SingleAsync();
+ Assert.Equal(id, saved.Id);
+ Assert.Equal("enc:{\"a\":1}", saved.ProtectedFcmCredentials);
+ Assert.Equal(FcmRegistrationStatus.Active, saved.Status);
+ Assert.Equal(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero), saved.UpdatedAt);
+ }
+
+ [Fact]
+ public async Task Upsert_SameOwner_RefreshesAndReactivates()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock());
+
+ var id = await store.UpsertAsync(10UL, 99UL, "old");
+ await store.SetStatusAsync(id, FcmRegistrationStatus.Expired);
+
+ var again = await store.UpsertAsync(10UL, 99UL, "new");
+
+ Assert.Equal(id, again);
+ var saved = await context.FcmRegistrations.SingleAsync();
+ Assert.Equal("enc:new", saved.ProtectedFcmCredentials);
+ Assert.Equal(FcmRegistrationStatus.Active, saved.Status);
+ }
+
+ [Fact]
+ public async Task ListActive_ReturnsOnlyActiveAcrossGuilds()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock());
+
+ await store.UpsertAsync(10UL, 1UL, "a");
+ var expiredId = await store.UpsertAsync(10UL, 2UL, "b");
+ await store.SetStatusAsync(expiredId, FcmRegistrationStatus.Expired);
+ await store.UpsertAsync(20UL, 3UL, "c");
+
+ var active = await store.ListActiveAsync();
+
+ Assert.Equal(2, active.Count);
+ Assert.All(active, r => Assert.Equal(FcmRegistrationStatus.Active, r.Status));
+ }
+
+ [Fact]
+ public async Task SetStatus_UpdatesStatusAndTimestamp()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock());
+
+ var id = await store.UpsertAsync(10UL, 99UL, "a");
+ await store.SetStatusAsync(id, FcmRegistrationStatus.Expired);
+
+ var saved = await context.FcmRegistrations.SingleAsync();
+ Assert.Equal(FcmRegistrationStatus.Expired, saved.Status);
+ Assert.Equal(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero), saved.UpdatedAt);
+ }
+
+ [Fact]
+ public async Task Get_ReturnsRegistrationForOwner_OrNull()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock());
+
+ await store.UpsertAsync(10UL, 99UL, "a");
+
+ Assert.NotNull(await store.GetAsync(10UL, 99UL));
+ Assert.Null(await store.GetAsync(10UL, 1UL));
+ }
+}
diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs
new file mode 100644
index 00000000..2f5bc289
--- /dev/null
+++ b/tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs
@@ -0,0 +1,75 @@
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Domain.Credentials;
+using RustPlusBot.Domain.Servers;
+
+namespace RustPlusBot.Persistence.Tests.Credentials;
+
+public sealed class PairingSchemaTests
+{
+ [Fact]
+ public async Task RemovingServer_CascadeDeletesItsCredentials()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+
+ var server = new RustServer
+ {
+ GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ context.PlayerCredentials.Add(new PlayerCredential
+ {
+ GuildId = 10UL,
+ RustServerId = server.Id,
+ OwnerUserId = 1UL,
+ SteamId = 1UL,
+ ProtectedPlayerToken = "x",
+ Status = CredentialStatus.Active
+ });
+ await context.SaveChangesAsync();
+
+ context.RustServers.Remove(server);
+ await context.SaveChangesAsync();
+
+ Assert.Empty(await context.PlayerCredentials.ToListAsync());
+ }
+
+ [Fact]
+ public async Task DuplicateServerEndpoint_ViolatesUniqueIndex()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+
+ context.RustServers.Add(new RustServer
+ {
+ GuildId = 10UL, Name = "A", Ip = "1.1.1.1", Port = 28015
+ });
+ context.RustServers.Add(new RustServer
+ {
+ GuildId = 10UL, Name = "B", Ip = "1.1.1.1", Port = 28015
+ });
+
+ await Assert.ThrowsAsync(() => context.SaveChangesAsync());
+ }
+
+ [Fact]
+ public async Task DuplicateRegistrationForOwner_ViolatesUniqueIndex()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+
+ context.FcmRegistrations.Add(new FcmRegistration
+ {
+ GuildId = 10UL, OwnerUserId = 1UL, ProtectedFcmCredentials = "a"
+ });
+ context.FcmRegistrations.Add(new FcmRegistration
+ {
+ GuildId = 10UL, OwnerUserId = 1UL, ProtectedFcmCredentials = "b"
+ });
+
+ await Assert.ThrowsAsync(() => context.SaveChangesAsync());
+ }
+}
diff --git a/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs b/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs
index 7ad23bac..f811dc00 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.Credentials;
using RustPlusBot.Persistence.Servers;
namespace RustPlusBot.Persistence.Tests;
@@ -29,5 +30,6 @@ public void AddBotPersistence_RegistersDbContextFactoryAndScopedServices()
// ICredentialStore is registered here, but its ICredentialProtector dependency is
// 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));
}
}
diff --git a/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs b/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs
index b188371a..8e8baaf6 100644
--- a/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs
+++ b/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs
@@ -50,4 +50,50 @@ public async Task RemoveAsync_OnlyRemovesWithinGuild()
Assert.True(await service.RemoveAsync(10UL, server.Id));
Assert.Empty(await service.ListAsync(10UL));
}
+
+ [Fact]
+ public async Task ResolveOrCreateByEndpoint_CreatesWhenNew()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var service = new ServerService(context);
+
+ var (server, created) = await service.ResolveOrCreateByEndpointAsync(10UL, 99UL, "Main", "1.2.3.4", 28015);
+
+ Assert.True(created);
+ Assert.NotEqual(Guid.Empty, server.Id);
+ Assert.Single(await service.ListAsync(10UL));
+ }
+
+ [Fact]
+ public async Task ResolveOrCreateByEndpoint_ReturnsExistingForSameEndpoint()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var service = new ServerService(context);
+
+ var first = await service.ResolveOrCreateByEndpointAsync(10UL, 1UL, "Main", "1.2.3.4", 28015);
+ var second = await service.ResolveOrCreateByEndpointAsync(10UL, 2UL, "Main again", "1.2.3.4", 28015);
+
+ Assert.True(first.Created);
+ Assert.False(second.Created);
+ Assert.Equal(first.Server.Id, second.Server.Id);
+ Assert.Single(await service.ListAsync(10UL));
+ }
+
+ [Fact]
+ public async Task ResolveOrCreateByEndpoint_DifferentPortIsDistinct()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var service = new ServerService(context);
+
+ await service.ResolveOrCreateByEndpointAsync(10UL, 1UL, "A", "1.2.3.4", 28015);
+ await service.ResolveOrCreateByEndpointAsync(10UL, 1UL, "B", "1.2.3.4", 28016);
+
+ Assert.Equal(2, (await service.ListAsync(10UL)).Count);
+ }
}