Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 14 additions & 4 deletions src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
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). A NEWLY INSERTED
/// credential is <c>Active</c> when <paramref name="markActive"/> is true (the owner whose pairing
/// first registered the server), otherwise <c>Standby</c>. Re-pairing refreshes the SteamId and token
/// and resets an <c>Invalid</c> credential to <c>Standby</c> (the existing designation is preserved;
/// <paramref name="markActive"/> is ignored on update). Returns the credential's id.
/// </summary>
/// <param name="request">The plaintext credential inputs.</param>
/// <param name="markActive">When inserting, whether this credential becomes the server's active identity.</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,
bool markActive,
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")]
58 changes: 58 additions & 0 deletions src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <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. 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);
}
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);
Loading
Loading