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
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Discord;
using Discord.Interactions;
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Features.Pairing.Pairing;
using RustPlusBot.Features.Pairing.Rendering;

namespace RustPlusBot.Features.Pairing.Modules;

/// <summary>Thin handler for the #setup server-pairing prompt buttons. Any guild member.</summary>
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
public sealed class ServerPairingComponentModule(IServiceScopeFactory scopeFactory)
: InteractionModuleBase<SocketInteractionContext>
{
private const string InvalidControlMessage = "That control wasn't valid.";
private const string ExpiredMessage = "This pairing expired — press \"Pair\" in-game again.";

/// <summary>Accepts a pending server pairing and triggers the full registration cycle.</summary>
/// <param name="tail">The "{ip}:{port}" custom-id tail.</param>
[ComponentInteraction(ServerPairingComponentIds.AcceptPrefix + "*")]
public async Task AcceptAsync(string tail)
{
if (!ServerPairingComponentIds.TryParseTail(tail, out var ip, out var port) || Context.Guild is null)
{
await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}

await DeferAsync(ephemeral: true).ConfigureAwait(false);
ServerPairingAcceptOutcome outcome;
var scope = scopeFactory.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
var coordinator = scope.ServiceProvider.GetRequiredService<IServerPairingCoordinator>();
outcome = await coordinator.TryAcceptAsync(Context.Guild.Id, ip, port, CancellationToken.None)
.ConfigureAwait(false);
}

if (outcome == ServerPairingAcceptOutcome.Expired)
{
// The prompt outlived its pending state (restart/double-click) — clean up the orphaned message.
await DeletePromptMessageSafeAsync().ConfigureAwait(false);
}

await FollowupAsync(outcome switch
{
ServerPairingAcceptOutcome.Added => "Server added.",
ServerPairingAcceptOutcome.AlreadyAdded => "That server was already added.",
_ => ExpiredMessage,
}, ephemeral: true).ConfigureAwait(false);
}

/// <summary>Dismisses a pending server pairing and removes the transient prompt message.</summary>
/// <param name="tail">The "{ip}:{port}" custom-id tail.</param>
[ComponentInteraction(ServerPairingComponentIds.DismissPrefix + "*")]
public async Task DismissAsync(string tail)
{
if (!ServerPairingComponentIds.TryParseTail(tail, out var ip, out var port) || Context.Guild is null)
{
await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
return;
}

bool dismissed;
var scope = scopeFactory.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
var coordinator = scope.ServiceProvider.GetRequiredService<IServerPairingCoordinator>();
dismissed = coordinator.TryDismiss(Context.Guild.Id, ip, port);
}

// Delete the actual prompt message that hosts this button (the component interaction's source
// message) — not the ephemeral interaction response. Best-effort: a delete failure is non-fatal.
await DeletePromptMessageSafeAsync().ConfigureAwait(false);
await RespondAsync(dismissed ? "Dismissed." : ExpiredMessage, ephemeral: true).ConfigureAwait(false);
}

private async Task DeletePromptMessageSafeAsync()
{
try
{
// The source message of a component interaction is the prompt that carries the button.
if (Context.Interaction is IComponentInteraction component)
{
await component.Message.DeleteAsync().ConfigureAwait(false);
}
}
#pragma warning disable CA1031 // Best-effort prompt cleanup; a delete failure is non-fatal.
catch (Exception ex)
#pragma warning restore CA1031
{
// Ignore: the prompt is transient and harmless if it lingers.
_ = ex;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,14 @@ public Task NotifyCredentialsExpiredAsync(
ownerUserId,
"Your Rust+ credentials were rejected. Reconnect your account in #setup to keep receiving pairings.",
cancellationToken);

/// <inheritdoc />
public Task NotifySetupChannelMissingAsync(
ulong guildId,
ulong ownerUserId,
CancellationToken cancellationToken = default) =>
dmSender.SendAsync(
ownerUserId,
"A server pairing arrived but this guild has no #setup channel. Run /setup, then press \"Pair\" in-game again.",
cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,12 @@ internal interface IOwnerNotifier
/// <param name="ownerUserId">The Discord user to notify.</param>
/// <param name="cancellationToken">A cancellation token.</param>
Task NotifyCredentialsExpiredAsync(ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default);

/// <summary>Tells the owner a server pairing arrived but no #setup channel exists to prompt in.</summary>
/// <param name="guildId">The guild the pairing belongs to.</param>
/// <param name="ownerUserId">The Discord user to notify.</param>
/// <param name="cancellationToken">A cancellation token.</param>
Task NotifySetupChannelMissingAsync(ulong guildId,
ulong ownerUserId,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using RustPlusBot.Features.Pairing.Listening;

namespace RustPlusBot.Features.Pairing.Pairing;

/// <summary>The outcome of accepting a pending server pairing.</summary>
internal enum ServerPairingAcceptOutcome
{
/// <summary>The server was created; the full registration cycle was triggered.</summary>
Added = 0,

/// <summary>The server already existed (race); the credential was upserted, no event fired.</summary>
AlreadyAdded = 1,

/// <summary>No pending pairing was held (restart or double-click); nothing was persisted.</summary>
Expired = 2,
}

/// <summary>Coordinates the "Add this server?" #setup prompt for new-server pairings.</summary>
internal interface IServerPairingCoordinator
{
/// <summary>Handles a new-server pairing: prompt in #setup and hold the notification pending.</summary>
/// <param name="guildId">The guild id.</param>
/// <param name="ownerUserId">The Discord user whose connected account produced the pairing.</param>
/// <param name="notification">The transient pairing notification (holds the Rust+ token).</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
Task HandleDetectedAsync(
ulong guildId,
ulong ownerUserId,
PairingNotification notification,
CancellationToken cancellationToken);

/// <summary>Accepts a pending pairing: persist server + credential and trigger registration. Race-guarded.</summary>
/// <param name="guildId">The guild id.</param>
/// <param name="ip">The server host or ip.</param>
/// <param name="port">The Rust+ app port.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The accept outcome.</returns>
Task<ServerPairingAcceptOutcome> TryAcceptAsync(
ulong guildId,
string ip,
int port,
CancellationToken cancellationToken);

/// <summary>Drops a pending pairing; returns whether one was held.</summary>
/// <param name="guildId">The guild id.</param>
/// <param name="ip">The server host or ip.</param>
/// <param name="port">The Rust+ app port.</param>
/// <returns>True when a pending pairing was removed.</returns>
bool TryDismiss(ulong guildId, string ip, int port);
}
31 changes: 17 additions & 14 deletions src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@

namespace RustPlusBot.Features.Pairing.Pairing;

/// <summary>Default <see cref="IPairingHandler"/>: resolve-or-create the server, upsert the credential, announce new servers.</summary>
/// <param name="servers">Server resolve-or-create.</param>
/// <summary>Default <see cref="IPairingHandler"/>: known servers get a silent credential upsert; new servers
/// are routed to the #setup confirmation prompt; entity pairings fan out to their feature events.</summary>
/// <param name="servers">Server lookup/backfill.</param>
/// <param name="credentials">The credential pool store.</param>
/// <param name="eventBus">Publishes the real ServerRegisteredEvent on new-server creation.</param>
/// <param name="eventBus">Publishes the entity paired events.</param>
/// <param name="serverPairings">Prompts for confirmation before a new server is registered.</param>
/// <param name="logger">The logger.</param>
internal sealed partial class PairingHandler(
IServerService servers,
ICredentialStore credentials,
IEventBus eventBus,
IServerPairingCoordinator serverPairings,
ILogger<PairingHandler> logger) : IPairingHandler
{
/// <inheritdoc />
Expand All @@ -31,29 +34,29 @@ public async Task HandleAsync(
return;
}

var (server, created) = await servers.ResolveOrCreateByEndpointAsync(
guildId, ownerUserId, notification.ServerName, notification.Ip, notification.Port, cancellationToken)
var existing = await servers.GetByEndpointAsync(guildId, notification.Ip, notification.Port, cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
// New server: nothing is persisted until the user accepts the #setup prompt.
await serverPairings.HandleDetectedAsync(guildId, ownerUserId, notification, cancellationToken)
.ConfigureAwait(false);
return;
}

// Only backfill a real Facepunch GUID. Persisting Guid.Empty would make every server that paired
// without one share the same id, breaking GUID-based entity-pairing attribution.
if (notification.FacepunchServerId != Guid.Empty)
{
await servers.SetFacepunchServerIdAsync(server.Id, notification.FacepunchServerId, cancellationToken)
await servers.SetFacepunchServerIdAsync(existing.Id, notification.FacepunchServerId, cancellationToken)
.ConfigureAwait(false);
}

await credentials.UpsertFromPairingAsync(
new StoreCredentialRequest(guildId, server.Id, ownerUserId, notification.PlayerId,
new StoreCredentialRequest(guildId, existing.Id, ownerUserId, notification.PlayerId,
notification.PlayerToken),
markActive: created,
markActive: false,
cancellationToken).ConfigureAwait(false);

if (created)
{
await eventBus.PublishAsync(new ServerRegisteredEvent(guildId, server.Id), cancellationToken)
.ConfigureAwait(false);
}
}

private async Task HandleEntityAsync(
Expand Down
Loading
Loading