Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.1" />
</ItemGroup>
<ItemGroup>
<!-- Test + sample -->
Expand Down
2 changes: 2 additions & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
<Project Path="src/RustPlusBot.Discord/RustPlusBot.Discord.csproj" />
<Project Path="src/RustPlusBot.Domain/RustPlusBot.Domain.csproj" />
<Project Path="src/RustPlusBot.Host/RustPlusBot.Host.csproj" />
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
</Folder>
Expand Down
12 changes: 8 additions & 4 deletions src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
namespace RustPlusBot.Abstractions.Credentials;

/// <summary>Persists and counts player credentials. Tokens are protected at rest.</summary>
/// <summary>Persists and counts per-server player credentials. Tokens are protected at rest.</summary>
public interface ICredentialStore
{
/// <summary>Stores a credential (as Standby) and returns its new id.</summary>
/// <summary>
/// Inserts or refreshes the credential for (GuildId, RustServerId, OwnerUserId). The first credential
/// stored for a server becomes <c>Active</c>; subsequent owners are <c>Standby</c>. Re-pairing refreshes
/// the SteamId and token and resets an <c>Invalid</c> credential to <c>Standby</c>. Returns the credential's id.
/// </summary>
/// <param name="request">The plaintext credential inputs.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The new credential's id.</returns>
Task<Guid> StoreAsync(StoreCredentialRequest request, CancellationToken cancellationToken = default);
/// <returns>The credential's id.</returns>
Task<Guid> UpsertFromPairingAsync(StoreCredentialRequest request, CancellationToken cancellationToken = default);

/// <summary>Counts stored credentials for a server within a guild.</summary>
/// <param name="guildId">Owning Discord guild snowflake.</param>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
namespace RustPlusBot.Abstractions.Credentials;

/// <summary>Plaintext inputs to register a credential; tokens are protected by the store before persistence.</summary>
/// <summary>Plaintext inputs to upsert a per-server player credential from a pairing; the token is protected before persistence.</summary>
/// <param name="GuildId">Owning Discord guild snowflake.</param>
/// <param name="RustServerId">The server this credential connects to.</param>
/// <param name="OwnerUserId">The Discord user registering the credential.</param>
/// <param name="SteamId">The player's Steam64 id.</param>
/// <param name="OwnerUserId">The Discord user who owns the paired identity.</param>
/// <param name="SteamId">The player's Steam64 id (from the pairing notification).</param>
/// <param name="PlayerToken">The plaintext Rust+ player token (protected before storage).</param>
/// <param name="FcmCredentialsJson">The plaintext FCM/Expo credential JSON (protected before storage).</param>
public sealed record StoreCredentialRequest(
ulong GuildId,
Guid RustServerId,
ulong OwnerUserId,
ulong SteamId,
string PlayerToken,
string FcmCredentialsJson);
string PlayerToken);
26 changes: 26 additions & 0 deletions src/RustPlusBot.Domain/Credentials/FcmRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace RustPlusBot.Domain.Credentials;

/// <summary>
/// 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.
/// </summary>
public sealed class FcmRegistration
{
/// <summary>Surrogate primary key.</summary>
public Guid Id { get; set; } = Guid.NewGuid();

/// <summary>The owning Discord guild snowflake.</summary>
public ulong GuildId { get; set; }

/// <summary>The Discord user who connected these credentials.</summary>
public ulong OwnerUserId { get; set; }

/// <summary>Protected FCM/Expo credentials JSON.</summary>
public string ProtectedFcmCredentials { get; set; } = string.Empty;

/// <summary>Listener lifecycle state.</summary>
public FcmRegistrationStatus Status { get; set; } = FcmRegistrationStatus.Active;

/// <summary>When the registration was last upserted or had its status changed (UTC).</summary>
public DateTimeOffset UpdatedAt { get; set; }
}
14 changes: 14 additions & 0 deletions src/RustPlusBot.Domain/Credentials/FcmRegistrationStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace RustPlusBot.Domain.Credentials;

/// <summary>Lifecycle state of a user's FCM listener registration.</summary>
public enum FcmRegistrationStatus
{
/// <summary>Credentials accepted; the listener should run.</summary>
Active = 0,

/// <summary>FCM rejected the credentials; the user must reconnect.</summary>
Expired = 1,

/// <summary>Removed by the user; the listener must not run.</summary>
Disabled = 2,
}
3 changes: 0 additions & 3 deletions src/RustPlusBot.Domain/Credentials/PlayerCredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ public sealed class PlayerCredential
/// <summary>Protected Rust+ player token.</summary>
public string ProtectedPlayerToken { get; set; } = string.Empty;

/// <summary>Protected FCM/Expo credential blob (JSON), used by the pairing listener later.</summary>
public string ProtectedFcmCredentials { get; set; } = string.Empty;

/// <summary>Pool lifecycle state.</summary>
public CredentialStatus Status { get; set; } = CredentialStatus.Standby;
}
3 changes: 3 additions & 0 deletions src/RustPlusBot.Features.Pairing/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("RustPlusBot.Features.Pairing.Tests")]
56 changes: 56 additions & 0 deletions src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using Discord.WebSocket;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RustPlusBot.Features.Pairing.Supervisor;

namespace RustPlusBot.Features.Pairing.Hosting;

/// <summary>Drives the supervisor from the host lifecycle: start listeners on Ready, stop them on shutdown.</summary>
/// <param name="client">The socket client (for the Ready event).</param>
/// <param name="supervisor">The pairing supervisor.</param>
/// <param name="logger">The logger.</param>
internal sealed partial class PairingHostedService(
DiscordSocketClient client,
IPairingSupervisor supervisor,
ILogger<PairingHostedService> logger) : IHostedService
{
private bool _started;

/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
client.Ready += OnReadyAsync;
return Task.CompletedTask;
}

/// <inheritdoc />
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.
if (_started)
{
return;
}

_started = true;
Comment on lines +17 to +43

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the plain bool here intentionally, for consistency with the repo's other two hosted services (DiscordBotService and WorkspaceHostedService), which rely on Discord.Net dispatching Ready serially on the gateway thread — I've expanded the comment in b8e2ec2 to document that rationale. _started is also set before the await, and a double-run would be benign anyway: EnsureListenerAsync stops-then-restarts a listener per owner idempotently, so the worst case is redundant work, not corruption. If you'd prefer, I can standardize all three hosted services on Interlocked.Exchange as a separate cleanup.

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);
}
27 changes: 27 additions & 0 deletions src/RustPlusBot.Features.Pairing/Listening/IPairingSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace RustPlusBot.Features.Pairing.Listening;

/// <summary>A live FCM listener for one user's credentials. Pushes pairing notifications to a callback.</summary>
internal interface IPairingListener : IAsyncDisposable
{
/// <summary>
/// Connects and waits up to <paramref name="timeout"/> for the first successful check-in. After a
/// <see cref="PairingConnectOutcome.Connected"/> result the listener keeps delivering notifications
/// to its callback until disposed.
/// </summary>
/// <param name="timeout">How long to wait for the initial check-in.</param>
/// <param name="cancellationToken">Cancels the connection.</param>
/// <returns>The initial connect outcome.</returns>
Task<PairingConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken);
}

/// <summary>Creates <see cref="IPairingListener"/>s from credentials. The seam that abstracts FCM for tests.</summary>
internal interface IPairingSource
{
/// <summary>Creates a listener that delivers notifications to <paramref name="onNotification"/>.</summary>
/// <param name="fcmCredentialsJson">The plaintext FCM credentials JSON.</param>
/// <param name="onNotification">Invoked for every pairing notification received.</param>
/// <returns>A not-yet-connected listener.</returns>
IPairingListener Create(
string fcmCredentialsJson,
Func<PairingNotification, CancellationToken, Task> onNotification);
}
39 changes: 39 additions & 0 deletions src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace RustPlusBot.Features.Pairing.Listening;

/// <summary>Whether a pairing notification is for a server or an in-game entity.</summary>
internal enum PairingKind
{
/// <summary>A server pairing (registers a server).</summary>
Server = 0,

/// <summary>An entity pairing (switch/alarm/camera) — ignored in 1b-i.</summary>
Entity = 1,
}

/// <summary>The outcome of an initial FCM connect attempt.</summary>
internal enum PairingConnectOutcome
{
/// <summary>Connected and checked in.</summary>
Connected = 0,

/// <summary>FCM rejected the credentials.</summary>
Rejected = 1,

/// <summary>No result within the probe window (network); retried in the background.</summary>
Timeout = 2,
}

/// <summary>A pairing notification delivered by a listener.</summary>
/// <param name="Kind">Server or entity.</param>
/// <param name="ServerName">The server's display name.</param>
/// <param name="Ip">The server host or ip.</param>
/// <param name="Port">The Rust+ app port.</param>
/// <param name="PlayerId">The paired player's Steam64 id.</param>
/// <param name="PlayerToken">The Rust+ player token for this (server, player).</param>
internal sealed record PairingNotification(
PairingKind Kind,
string ServerName,
string Ip,
int Port,
ulong PlayerId,
string PlayerToken);
119 changes: 119 additions & 0 deletions src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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;

/// <summary>Real <see cref="IPairingSource"/> backed by RustPlusApi.Fcm. Untested by unit tests (integration shim).</summary>
/// <param name="logger">The logger.</param>
internal sealed partial class RustPlusFcmPairingSource(ILogger<RustPlusFcmPairingSource> logger) : IPairingSource
{
/// <inheritdoc />
public IPairingListener Create(
string fcmCredentialsJson,
Func<PairingNotification, CancellationToken, Task> onNotification) =>
new RustPlusFcmListener(fcmCredentialsJson, onNotification, logger);

private sealed partial class RustPlusFcmListener : IPairingListener
Comment on lines +13 to +46

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b8e2ec2. Create is now non-throwing: a construction/deserialization failure is logged and returns a RejectedListener whose ConnectAsync returns Rejected, so the supervisor reports the outcome as rejected and the modal shows "those credentials were rejected" instead of "still verifying". Added RustPlusFcmPairingSourceTests asserting Create doesn't throw and yields Rejected for unparseable credentials.

{
private static readonly JsonSerializerOptions JsonOptions =
new()
{
PropertyNameCaseInsensitive = true
};

private readonly RustPlusFcm _fcm;
private readonly ILogger _logger;
private readonly Func<PairingNotification, CancellationToken, Task> _onNotification;

public RustPlusFcmListener(
string fcmCredentialsJson,
Func<PairingNotification, CancellationToken, Task> onNotification,
ILogger logger)
{
var credentials = JsonSerializer.Deserialize<Credentials>(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;
}

/// <inheritdoc />
public async Task<PairingConnectOutcome> 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;
}
}

/// <inheritdoc />
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<ServerEvent?> 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);
}
});
}
}
}
23 changes: 23 additions & 0 deletions src/RustPlusBot.Features.Pairing/Modules/ConnectModal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Discord;
using Discord.Interactions;

namespace RustPlusBot.Features.Pairing.Modules;

/// <summary>The ephemeral modal that collects a user's FCM credentials JSON.</summary>
public sealed class ConnectModal : IModal
{
/// <summary>Custom id of the modal, handled by <see cref="CredentialModule"/>.</summary>
public const string ModalId = "pairing:connect:modal";

/// <summary>Custom id of the credentials text input.</summary>
public const string CredentialsInputId = "pairing:connect:credentials";

/// <summary>The pasted FCM credentials JSON.</summary>
[InputLabel("FCM credentials JSON")]
[ModalTextInput(CredentialsInputId, TextInputStyle.Paragraph,
placeholder: "Paste the JSON from the credentials helper")]
public string Credentials { get; set; } = string.Empty;

/// <inheritdoc />
public string Title => "Connect your Rust+ account";
}
Loading
Loading