-
Notifications
You must be signed in to change notification settings - Fork 0
Subsystem 1b-i: credential intake & FCM pairing → server registration #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 14 commits
9d70a47
8c49542
fa8934b
e6abeb5
b1b0ad1
485c72a
33d7435
d27daa5
adcafd0
887153e
e99f26f
192b3fe
ac5fff5
4766065
b8e2ec2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); |
| 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; } | ||
| } |
| 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, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| using System.Runtime.CompilerServices; | ||
|
|
||
| [assembly: InternalsVisibleTo("RustPlusBot.Features.Pairing.Tests")] |
| 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; | ||
| 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); | ||
| } | ||
| 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); | ||
| } |
| 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); |
| 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
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in b8e2ec2. |
||
| { | ||
| 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); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| 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"; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kept the plain
boolhere intentionally, for consistency with the repo's other two hosted services (DiscordBotServiceandWorkspaceHostedService), which rely on Discord.Net dispatchingReadyserially on the gateway thread — I've expanded the comment in b8e2ec2 to document that rationale._startedis also set before theawait, and a double-run would be benign anyway:EnsureListenerAsyncstops-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 onInterlocked.Exchangeas a separate cleanup.