diff --git a/src/RustPlusBot.Features.Pairing/Modules/ServerPairingComponentModule.cs b/src/RustPlusBot.Features.Pairing/Modules/ServerPairingComponentModule.cs
new file mode 100644
index 00000000..faec51c4
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Modules/ServerPairingComponentModule.cs
@@ -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;
+
+/// Thin handler for the #setup server-pairing prompt buttons. Any guild member.
+/// Creates a short-lived DI scope per interaction.
+public sealed class ServerPairingComponentModule(IServiceScopeFactory scopeFactory)
+ : InteractionModuleBase
+{
+ private const string InvalidControlMessage = "That control wasn't valid.";
+ private const string ExpiredMessage = "This pairing expired — press \"Pair\" in-game again.";
+
+ /// Accepts a pending server pairing and triggers the full registration cycle.
+ /// The "{ip}:{port}" custom-id tail.
+ [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();
+ 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);
+ }
+
+ /// Dismisses a pending server pairing and removes the transient prompt message.
+ /// The "{ip}:{port}" custom-id tail.
+ [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();
+ 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;
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs b/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs
index 459901a9..b0c096aa 100644
--- a/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs
+++ b/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs
@@ -15,4 +15,14 @@ public Task NotifyCredentialsExpiredAsync(
ownerUserId,
"Your Rust+ credentials were rejected. Reconnect your account in #setup to keep receiving pairings.",
cancellationToken);
+
+ ///
+ 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);
}
diff --git a/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs b/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs
index e25ce220..399e1c4f 100644
--- a/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs
+++ b/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs
@@ -8,4 +8,12 @@ internal interface IOwnerNotifier
/// The Discord user to notify.
/// A cancellation token.
Task NotifyCredentialsExpiredAsync(ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default);
+
+ /// Tells the owner a server pairing arrived but no #setup channel exists to prompt in.
+ /// The guild the pairing belongs to.
+ /// The Discord user to notify.
+ /// A cancellation token.
+ Task NotifySetupChannelMissingAsync(ulong guildId,
+ ulong ownerUserId,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/RustPlusBot.Features.Pairing/Pairing/IServerPairingCoordinator.cs b/src/RustPlusBot.Features.Pairing/Pairing/IServerPairingCoordinator.cs
new file mode 100644
index 00000000..bb128a66
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Pairing/IServerPairingCoordinator.cs
@@ -0,0 +1,50 @@
+using RustPlusBot.Features.Pairing.Listening;
+
+namespace RustPlusBot.Features.Pairing.Pairing;
+
+/// The outcome of accepting a pending server pairing.
+internal enum ServerPairingAcceptOutcome
+{
+ /// The server was created; the full registration cycle was triggered.
+ Added = 0,
+
+ /// The server already existed (race); the credential was upserted, no event fired.
+ AlreadyAdded = 1,
+
+ /// No pending pairing was held (restart or double-click); nothing was persisted.
+ Expired = 2,
+}
+
+/// Coordinates the "Add this server?" #setup prompt for new-server pairings.
+internal interface IServerPairingCoordinator
+{
+ /// Handles a new-server pairing: prompt in #setup and hold the notification pending.
+ /// The guild id.
+ /// The Discord user whose connected account produced the pairing.
+ /// The transient pairing notification (holds the Rust+ token).
+ /// A token to cancel the operation.
+ Task HandleDetectedAsync(
+ ulong guildId,
+ ulong ownerUserId,
+ PairingNotification notification,
+ CancellationToken cancellationToken);
+
+ /// Accepts a pending pairing: persist server + credential and trigger registration. Race-guarded.
+ /// The guild id.
+ /// The server host or ip.
+ /// The Rust+ app port.
+ /// A token to cancel the operation.
+ /// The accept outcome.
+ Task TryAcceptAsync(
+ ulong guildId,
+ string ip,
+ int port,
+ CancellationToken cancellationToken);
+
+ /// Drops a pending pairing; returns whether one was held.
+ /// The guild id.
+ /// The server host or ip.
+ /// The Rust+ app port.
+ /// True when a pending pairing was removed.
+ bool TryDismiss(ulong guildId, string ip, int port);
+}
diff --git a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
index 1343e13e..f5c13836 100644
--- a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
+++ b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
@@ -6,15 +6,18 @@
namespace RustPlusBot.Features.Pairing.Pairing;
-/// Default : resolve-or-create the server, upsert the credential, announce new servers.
-/// Server resolve-or-create.
+/// Default : known servers get a silent credential upsert; new servers
+/// are routed to the #setup confirmation prompt; entity pairings fan out to their feature events.
+/// Server lookup/backfill.
/// The credential pool store.
-/// Publishes the real ServerRegisteredEvent on new-server creation.
+/// Publishes the entity paired events.
+/// Prompts for confirmation before a new server is registered.
/// The logger.
internal sealed partial class PairingHandler(
IServerService servers,
ICredentialStore credentials,
IEventBus eventBus,
+ IServerPairingCoordinator serverPairings,
ILogger logger) : IPairingHandler
{
///
@@ -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(
diff --git a/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs
new file mode 100644
index 00000000..597e9538
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs
@@ -0,0 +1,191 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Domain.Servers;
+using RustPlusBot.Features.Pairing.Listening;
+using RustPlusBot.Features.Pairing.Notifications;
+using RustPlusBot.Features.Pairing.Posting;
+using RustPlusBot.Features.Pairing.Rendering;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Servers;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Pairing.Pairing;
+
+/// Turns a new-server pairing into an "Add it?" prompt in #setup and, on Accept, a registered server.
+/// Pending state is in-memory only (mirrors the entity coordinators): lost on restart, re-pair to re-prompt.
+/// A repeat detection re-ensures the prompt (self-healing a deleted message) rather than stranding pending.
+/// Detections are serialized through a gate so concurrent pairings for one endpoint post a single prompt.
+/// Opens scopes for the scoped server/credential/workspace stores.
+/// Resolves the guild's #setup channel id.
+/// Posts/edits the prompt message.
+/// Renders the prompt and confirmation embeds.
+/// DMs the owner when no #setup channel exists.
+/// Publishes ServerRegisteredEvent on accepted creation.
+/// The logger.
+internal sealed partial class ServerPairingCoordinator(
+ IServiceScopeFactory scopeFactory,
+ ISetupChannelLocator locator,
+ ISetupChannelPoster poster,
+ ServerPairingPromptRenderer renderer,
+ IOwnerNotifier ownerNotifier,
+ IEventBus eventBus,
+ ILogger logger) : IServerPairingCoordinator, IDisposable
+{
+ private readonly SemaphoreSlim _detectGate = new(1, 1);
+ private readonly ConcurrentDictionary<(ulong Guild, string Ip, int Port), Pending> _pending = new();
+
+ ///
+ public void Dispose() => _detectGate.Dispose();
+
+ ///
+ public async Task HandleDetectedAsync(
+ ulong guildId,
+ ulong ownerUserId,
+ PairingNotification notification,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(notification);
+
+ // Serialize detections: without the gate, two concurrent pairings for the same endpoint could
+ // both see "not pending" and post duplicate prompts (check-then-await-then-set race).
+ await _detectGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var key = (guildId, notification.Ip, notification.Port);
+ _pending.TryGetValue(key, out var existing);
+
+ var channelId = await locator.GetChannelIdAsync(guildId, cancellationToken).ConfigureAwait(false);
+ if (channelId is not { } channel)
+ {
+ // No #setup to prompt in — drop any stale pending so a later /setup + re-pair starts clean.
+ _pending.TryRemove(key, out _);
+ LogSetupChannelMissing(logger, guildId);
+ await ownerNotifier.NotifySetupChannelMissingAsync(guildId, ownerUserId, cancellationToken)
+ .ConfigureAwait(false);
+ return;
+ }
+
+ var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
+ var (embed, components) =
+ renderer.RenderPrompt(notification.ServerName, notification.Ip, notification.Port, culture);
+
+ // Pass the known message id (null on first detection) so an edit self-heals a deleted prompt and a
+ // repeat "Pair" press re-ensures rather than posting a duplicate. The latest token always wins.
+ var messageId = await poster.EnsureAsync(channel, existing?.MessageId, embed, components, cancellationToken)
+ .ConfigureAwait(false);
+ if (messageId is not { } mid)
+ {
+ if (existing is null)
+ {
+ // First prompt failed to post — leave nothing pending so a later re-pair retries.
+ LogPromptPostFailed(logger, guildId);
+ return;
+ }
+
+ // Transient re-ensure failure with a prompt already shown — keep the live prompt/pending and
+ // just refresh the token so a later re-pair can retry the heal.
+ _pending[key] = new Pending(ownerUserId, notification, existing.MessageId);
+ return;
+ }
+
+ _pending[key] = new Pending(ownerUserId, notification, mid);
+ }
+ finally
+ {
+ _detectGate.Release();
+ }
+ }
+
+ ///
+ public async Task TryAcceptAsync(
+ ulong guildId,
+ string ip,
+ int port,
+ CancellationToken cancellationToken)
+ {
+ if (!_pending.TryRemove((guildId, ip, port), out var pending))
+ {
+ return ServerPairingAcceptOutcome.Expired;
+ }
+
+ var notification = pending.Notification;
+ RustServer server;
+ bool created;
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ // Replays the pre-confirmation registration path verbatim (see PairingHandler history).
+ var servers = scope.ServiceProvider.GetRequiredService();
+ var credentials = scope.ServiceProvider.GetRequiredService();
+ (server, created) = await servers.ResolveOrCreateByEndpointAsync(
+ guildId, pending.OwnerUserId, notification.ServerName, notification.Ip, notification.Port,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ // 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)
+ .ConfigureAwait(false);
+ }
+
+ await credentials.UpsertFromPairingAsync(
+ new StoreCredentialRequest(guildId, server.Id, pending.OwnerUserId, notification.PlayerId,
+ notification.PlayerToken),
+ markActive: created,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ if (created)
+ {
+ await eventBus.PublishAsync(new ServerRegisteredEvent(guildId, server.Id), cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ var channelId = await locator.GetChannelIdAsync(guildId, cancellationToken).ConfigureAwait(false);
+ if (channelId is { } channel)
+ {
+ var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
+ var (embed, components) = renderer.RenderAdded(notification.ServerName, culture);
+ _ = await poster.EnsureAsync(channel, pending.MessageId, embed, components, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ return created ? ServerPairingAcceptOutcome.Added : ServerPairingAcceptOutcome.AlreadyAdded;
+ }
+
+ ///
+ public bool TryDismiss(ulong guildId, string ip, int port) => _pending.TryRemove((guildId, ip, port), out _);
+
+ /// Gets whether a pairing is pending for the endpoint (test seam).
+ /// The guild id.
+ /// The server host or ip.
+ /// The Rust+ app port.
+ /// True when a prompt is pending.
+ public bool HasPending(ulong guildId, string ip, int port) => _pending.ContainsKey((guildId, ip, port));
+
+ private async Task GetCultureAsync(ulong guildId, CancellationToken ct)
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ return await store.GetCultureAsync(guildId, ct).ConfigureAwait(false);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning,
+ Message = "Server pairing for guild {GuildId} dropped: no #setup channel to prompt in.")]
+ private static partial void LogSetupChannelMissing(ILogger logger, ulong guildId);
+
+ [LoggerMessage(Level = LogLevel.Warning,
+ Message = "Server-pairing prompt for guild {GuildId} could not be posted to #setup; " +
+ "pairing dropped — pair again in-game to retry.")]
+ private static partial void LogPromptPostFailed(ILogger logger, ulong guildId);
+
+ private sealed record Pending(ulong OwnerUserId, PairingNotification Notification, ulong? MessageId);
+}
diff --git a/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs
index 409d8aae..bbe2191d 100644
--- a/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs
@@ -5,7 +5,10 @@
using RustPlusBot.Features.Pairing.Listening;
using RustPlusBot.Features.Pairing.Notifications;
using RustPlusBot.Features.Pairing.Pairing;
+using RustPlusBot.Features.Pairing.Posting;
+using RustPlusBot.Features.Pairing.Rendering;
using RustPlusBot.Features.Pairing.Supervisor;
+using RustPlusBot.Localization;
namespace RustPlusBot.Features.Pairing;
@@ -25,6 +28,11 @@ public static IServiceCollection AddPairing(this IServiceCollection services)
services.AddSingleton();
services.AddScoped();
+ services.AddRustPlusBotLocalization();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
// Contribute this assembly's interaction modules to the Discord layer.
services.AddSingleton(new InteractionModuleAssembly(typeof(PairingServiceCollectionExtensions).Assembly));
diff --git a/src/RustPlusBot.Features.Pairing/Posting/DiscordSetupChannelPoster.cs b/src/RustPlusBot.Features.Pairing/Posting/DiscordSetupChannelPoster.cs
new file mode 100644
index 00000000..db96c3eb
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Posting/DiscordSetupChannelPoster.cs
@@ -0,0 +1,22 @@
+using Discord;
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Discord.Posting;
+
+namespace RustPlusBot.Features.Pairing.Posting;
+
+/// Posts/edits server-pairing prompts in #setup by message id. Untested integration shim.
+/// The shared gated channel messenger.
+/// The logger.
+internal sealed class DiscordSetupChannelPoster(
+ DiscordChannelMessenger messenger,
+ ILogger logger) : ISetupChannelPoster
+{
+ ///
+ public Task EnsureAsync(
+ ulong channelId,
+ ulong? messageId,
+ Embed embed,
+ MessageComponent components,
+ CancellationToken cancellationToken)
+ => messenger.EnsureAsync(channelId, messageId, embed, components, logger, cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Pairing/Posting/ISetupChannelPoster.cs b/src/RustPlusBot.Features.Pairing/Posting/ISetupChannelPoster.cs
new file mode 100644
index 00000000..5475e868
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Posting/ISetupChannelPoster.cs
@@ -0,0 +1,19 @@
+namespace RustPlusBot.Features.Pairing.Posting;
+
+/// Posts/edits server-pairing prompt messages in #setup by message id, self-healing a deleted message.
+internal interface ISetupChannelPoster
+{
+ /// Edits the message at if present and found; otherwise posts a new one.
+ /// The #setup channel id.
+ /// The known prompt message id, or null to post fresh.
+ /// The embed to show.
+ /// The button row (may be empty).
+ /// A cancellation token.
+ /// The (possibly new) message id, or null on failure.
+ Task EnsureAsync(
+ ulong channelId,
+ ulong? messageId,
+ global::Discord.Embed embed,
+ global::Discord.MessageComponent components,
+ CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Pairing/Rendering/ServerPairingComponentIds.cs b/src/RustPlusBot.Features.Pairing/Rendering/ServerPairingComponentIds.cs
new file mode 100644
index 00000000..c4998873
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Rendering/ServerPairingComponentIds.cs
@@ -0,0 +1,50 @@
+using System.Globalization;
+
+namespace RustPlusBot.Features.Pairing.Rendering;
+
+/// Custom ids for the server-pairing prompt in #setup. Tails encode "{ip}:{port}".
+internal static class ServerPairingComponentIds
+{
+ /// Prompt Accept button; tail "{ip}:{port}".
+ public const string AcceptPrefix = "server:accept:";
+
+ /// Prompt Dismiss button; tail "{ip}:{port}".
+ public const string DismissPrefix = "server:dismiss:";
+
+ /// Builds the "{ip}:{port}" custom-id tail for an endpoint.
+ /// The server host or ip.
+ /// The Rust+ app port.
+ /// The custom-id tail.
+ public static string Tail(string ip, int port) =>
+ ip + ":" + port.ToString(CultureInfo.InvariantCulture);
+
+ /// Parses an "{ip}:{port}" tail. Splits on the last ':' so IPv6 hosts keep their colons.
+ /// The custom-id tail.
+ /// The parsed host or ip.
+ /// The parsed port (1-65535).
+ /// True when the tail carried a well-formed endpoint.
+ public static bool TryParseTail(string? tail, out string ip, out int port)
+ {
+ ip = string.Empty;
+ port = 0;
+ if (string.IsNullOrEmpty(tail))
+ {
+ return false;
+ }
+
+ var sep = tail.LastIndexOf(':');
+ if (sep <= 0 || sep == tail.Length - 1)
+ {
+ return false;
+ }
+
+ if (!int.TryParse(tail[(sep + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out port)
+ || port is < 1 or > 65535)
+ {
+ return false;
+ }
+
+ ip = tail[..sep];
+ return true;
+ }
+}
diff --git a/src/RustPlusBot.Features.Pairing/Rendering/ServerPairingPromptRenderer.cs b/src/RustPlusBot.Features.Pairing/Rendering/ServerPairingPromptRenderer.cs
new file mode 100644
index 00000000..c1bba3d1
--- /dev/null
+++ b/src/RustPlusBot.Features.Pairing/Rendering/ServerPairingPromptRenderer.cs
@@ -0,0 +1,51 @@
+using Discord;
+using RustPlusBot.Localization;
+
+namespace RustPlusBot.Features.Pairing.Rendering;
+
+/// Renders the "New server detected — Add it?" prompt and the post-accept confirmation. Pure.
+/// The shared localizer.
+internal sealed class ServerPairingPromptRenderer(ILocalizer localizer)
+{
+ /// Renders the transient server-pairing prompt with Accept/Dismiss buttons.
+ /// The server's display name from the pairing.
+ /// The server host or ip.
+ /// The Rust+ app port.
+ /// The guild culture.
+ /// The prompt embed and Accept/Dismiss row.
+ public (Embed Embed, MessageComponent Components) RenderPrompt(
+ string serverName,
+ string ip,
+ int port,
+ string culture)
+ {
+ var embed = new EmbedBuilder()
+ .WithTitle(localizer.Get("server.prompt.title", culture))
+ .WithDescription(localizer.Get("server.prompt.body", culture, serverName, ip, port))
+ .Build();
+
+ var tail = ServerPairingComponentIds.Tail(ip, port);
+ var components = new ComponentBuilder()
+ .WithButton(localizer.Get("server.prompt.accept", culture), ServerPairingComponentIds.AcceptPrefix + tail,
+ ButtonStyle.Success)
+ .WithButton(localizer.Get("server.prompt.dismiss", culture),
+ ServerPairingComponentIds.DismissPrefix + tail, ButtonStyle.Secondary)
+ .Build();
+
+ return (embed, components);
+ }
+
+ /// Renders the buttonless confirmation the prompt is edited into after Accept.
+ /// The server's display name.
+ /// The guild culture.
+ /// The confirmation embed and an empty component row.
+ public (Embed Embed, MessageComponent Components) RenderAdded(string serverName, string culture)
+ {
+ var embed = new EmbedBuilder()
+ .WithTitle(localizer.Get("server.prompt.added.title", culture))
+ .WithDescription(localizer.Get("server.prompt.added.body", culture, serverName))
+ .Build();
+
+ return (embed, new ComponentBuilder().Build());
+ }
+}
diff --git a/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj b/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj
index 9bb67764..48d6298a 100644
--- a/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj
+++ b/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/RustPlusBot.Features.Workspace/Locating/ISetupChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/ISetupChannelLocator.cs
new file mode 100644
index 00000000..40fc9ed8
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Locating/ISetupChannelLocator.cs
@@ -0,0 +1,11 @@
+namespace RustPlusBot.Features.Workspace.Locating;
+
+/// Resolves the guild-global #setup channel (used to post server-pairing prompts).
+public interface ISetupChannelLocator
+{
+ /// Gets the Discord channel id of #setup for , or null.
+ /// The guild snowflake.
+ /// A cancellation token.
+ /// The Discord channel id, or null if not provisioned.
+ Task GetChannelIdAsync(ulong guildId, CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Workspace/Locating/SetupChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/SetupChannelLocator.cs
new file mode 100644
index 00000000..32fc3c84
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Locating/SetupChannelLocator.cs
@@ -0,0 +1,72 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Workspace.Locating;
+
+/// Caches the provisioned global #setup channel per guild. Sibling of ;
+/// separate because #setup rows carry a null RustServerId, which the per-server base skips.
+/// Opens scopes for the scoped workspace store.
+/// Drives the cache TTL.
+internal sealed class SetupChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
+ : ISetupChannelLocator, IDisposable
+{
+ private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
+ private readonly SemaphoreSlim _refreshGate = new(1, 1);
+ private DateTimeOffset _builtAt = DateTimeOffset.MinValue;
+ private Dictionary _byGuild = [];
+
+ ///
+ public void Dispose() => _refreshGate.Dispose();
+
+ ///
+ public async Task GetChannelIdAsync(ulong guildId, CancellationToken cancellationToken)
+ {
+ await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
+ return _byGuild.TryGetValue(guildId, out var id) ? id : null;
+ }
+
+ private async Task EnsureFreshAsync(CancellationToken cancellationToken)
+ {
+ if (clock.UtcNow - _builtAt < CacheTtl)
+ {
+ return;
+ }
+
+ await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (clock.UtcNow - _builtAt < CacheTtl)
+ {
+ return;
+ }
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ var rows = await store.GetChannelsByKeyAsync(WorkspaceChannelKeys.Setup, cancellationToken)
+ .ConfigureAwait(false);
+
+ Dictionary byGuild = [];
+ foreach (var row in rows)
+ {
+ // #setup is global-scope: only rows without a server id belong to it.
+ if (row.RustServerId is not null)
+ {
+ continue;
+ }
+
+ byGuild[row.GuildId] = row.DiscordChannelId;
+ }
+
+ _byGuild = byGuild;
+ _builtAt = clock.UtcNow;
+ }
+ }
+ finally
+ {
+ _refreshGate.Release();
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
index a112790b..3ef020e2 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
@@ -70,6 +70,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddHostedService();
diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx
index facc37f4..071261a0 100644
--- a/src/RustPlusBot.Localization/Strings.fr.resx
+++ b/src/RustPlusBot.Localization/Strings.fr.resx
@@ -663,6 +663,24 @@
{0}/{1} en ligne · chef {2}
+
+ Accepter
+
+
+ {0} a été ajouté. Ses salons sont en cours de création et le bot se connecte.
+
+
+ Serveur ajouté
+
+
+ Nouvel appairage de serveur détecté : {0} ({1}:{2}). L'ajouter ?
+
+
+ Ignorer
+
+
+ Nouveau serveur détecté
+
Configurez le bot pour ce serveur.
diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx
index 55063fce..b2dc63e7 100644
--- a/src/RustPlusBot.Localization/Strings.resx
+++ b/src/RustPlusBot.Localization/Strings.resx
@@ -663,6 +663,24 @@
{0}/{1} online · leader {2}
+
+ Accept
+
+
+ {0} was added. Its channels are being created and the bot is connecting.
+
+
+ Server added
+
+
+ Detected a new server pairing: {0} ({1}:{2}). Add it?
+
+
+ Dismiss
+
+
+ New server detected
+
Configure the bot for this server.
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/DiscordOwnerNotifierTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/DiscordOwnerNotifierTests.cs
index b0c7822c..1a881378 100644
--- a/tests/RustPlusBot.Features.Pairing.Tests/DiscordOwnerNotifierTests.cs
+++ b/tests/RustPlusBot.Features.Pairing.Tests/DiscordOwnerNotifierTests.cs
@@ -19,4 +19,16 @@ await dm.Received(1).SendAsync(
Arg.Is(m => m.Contains("Reconnect", StringComparison.Ordinal)),
Arg.Any());
}
+
+ [Fact]
+ public async Task NotifySetupChannelMissing_dms_owner_with_setup_instructions()
+ {
+ var dm = Substitute.For();
+ var notifier = new DiscordOwnerNotifier(dm);
+
+ await notifier.NotifySetupChannelMissingAsync(10UL, 99UL, CancellationToken.None);
+
+ await dm.Received(1).SendAsync(99UL,
+ Arg.Is(m => m.Contains("/setup", StringComparison.Ordinal)), Arg.Any());
+ }
}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs
index 18a8e64c..3f50f233 100644
--- a/tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs
+++ b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs
@@ -9,6 +9,9 @@ internal sealed class RecordingOwnerNotifier : IOwnerNotifier
/// All (guild, owner) pairs that received an expiry notification.
public ConcurrentBag<(ulong Guild, ulong Owner)> Notified { get; } = [];
+ /// All (guild, owner) pairs told that #setup is missing.
+ public ConcurrentBag<(ulong Guild, ulong Owner)> SetupMissing { get; } = [];
+
///
public Task NotifyCredentialsExpiredAsync(ulong guildId,
ulong ownerUserId,
@@ -17,4 +20,13 @@ public Task NotifyCredentialsExpiredAsync(ulong guildId,
Notified.Add((guildId, ownerUserId));
return Task.CompletedTask;
}
+
+ ///
+ public Task NotifySetupChannelMissingAsync(ulong guildId,
+ ulong ownerUserId,
+ CancellationToken cancellationToken = default)
+ {
+ SetupMissing.Add((guildId, ownerUserId));
+ return Task.CompletedTask;
+ }
}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs
index bce5bc8c..4a893949 100644
--- a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs
+++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs
@@ -4,6 +4,7 @@
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Domain.Credentials;
using RustPlusBot.Domain.Entities;
+using RustPlusBot.Domain.Servers;
using RustPlusBot.Features.Pairing.Listening;
using RustPlusBot.Features.Pairing.Pairing;
using RustPlusBot.Persistence;
@@ -23,9 +24,27 @@ private static ICredentialProtector PassThrough()
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 (PairingHandler Handler, IServerPairingCoordinator Coordinator) CreateHandler(
+ BotDbContext context,
+ IEventBus bus)
+ {
+ var coordinator = Substitute.For();
+ var handler = new PairingHandler(new ServerService(context), new CredentialStore(context, PassThrough()),
+ bus, coordinator, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
+ return (handler, coordinator);
+ }
+
+ private static async Task SeedServerAsync(BotDbContext context, Guid? facepunchServerId = null)
+ {
+ var service = new ServerService(context);
+ var server = await service.AddAsync(10UL, 99UL, "Rustopia", "1.2.3.4", 28015);
+ if (facepunchServerId is { } fp)
+ {
+ await service.SetFacepunchServerIdAsync(server.Id, fp);
+ }
+
+ return server;
+ }
private static PairingNotification ServerPairing(string ip = "1.2.3.4", int port = 28015, ulong steam = 7UL) =>
new(PairingKind.Server, "Rustopia", ip, port, steam, "ptoken", FacepunchServerId: FpServer, EntityId: 0UL);
@@ -43,54 +62,52 @@ private static PairingNotification StorageMonitorPairing(Guid fpServer, ulong en
FacepunchServerId: fpServer, EntityId: entityId, EntityKind: PairedEntityKind.StorageMonitor);
[Fact]
- public async Task ServerPairing_CreatesServerCredentialAndFiresEventOnce()
+ public async Task NewServerPairing_RoutesToCoordinator_PersistsNothing()
{
var (context, connection) = TestDb.Create();
await using var _ = context;
await using var __ = connection;
var bus = Substitute.For();
- var handler = CreateHandler(context, bus);
+ var (handler, coordinator) = 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());
+ await coordinator.Received(1).HandleDetectedAsync(10UL, 99UL,
+ Arg.Is(n => n.Ip == "1.2.3.4" && n.Port == 28015), Arg.Any());
+ Assert.Empty(await context.RustServers.ToListAsync());
+ Assert.Empty(await context.PlayerCredentials.ToListAsync());
+ await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any());
}
[Fact]
- public async Task SecondOwnerSameServer_AddsStandbyCredentialNoEvent()
+ public async Task ExistingServerPairing_AddsStandbyCredential_NoEventNoPrompt()
{
var (context, connection) = TestDb.Create();
await using var _ = context;
await using var __ = connection;
var bus = Substitute.For();
- var handler = CreateHandler(context, bus);
+ var (handler, coordinator) = CreateHandler(context, bus);
+ await SeedServerAsync(context);
- 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);
+ var credential = await context.PlayerCredentials.SingleAsync(c => c.OwnerUserId == 2UL);
+ Assert.Equal(CredentialStatus.Standby, credential.Status);
await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any());
+ await coordinator.DidNotReceive().HandleDetectedAsync(Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any());
}
[Fact]
- public async Task ServerPairing_BackfillsFacepunchServerId()
+ public async Task ExistingServerPairing_BackfillsFacepunchServerId()
{
var (context, connection) = TestDb.Create();
await using var _ = context;
await using var __ = connection;
var bus = Substitute.For();
- var handler = CreateHandler(context, bus);
+ var (handler, _) = CreateHandler(context, bus);
+ await SeedServerAsync(context); // no Facepunch id yet
await handler.HandleAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
@@ -105,11 +122,8 @@ public async Task EntityPairing_KnownServer_PublishesSwitchPairedEvent()
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();
- bus.ClearReceivedCalls();
+ var (handler, _) = CreateHandler(context, bus);
+ var server = await SeedServerAsync(context, FpServer);
await handler.HandleAsync(10UL, 1UL, EntityPairing(FpServer, entityId: 42UL), CancellationToken.None);
@@ -125,7 +139,7 @@ public async Task EntityPairing_UnknownServer_DropsAndCreatesNothing()
await using var _ = context;
await using var __ = connection;
var bus = Substitute.For();
- var handler = CreateHandler(context, bus);
+ var (handler, _) = CreateHandler(context, bus);
await handler.HandleAsync(10UL, 1UL, EntityPairing(Guid.NewGuid(), 42UL), CancellationToken.None);
@@ -140,10 +154,8 @@ public async Task EntityPairing_Alarm_PublishesAlarmPairedEvent_NotSwitch()
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();
- bus.ClearReceivedCalls();
+ var (handler, _) = CreateHandler(context, bus);
+ var server = await SeedServerAsync(context, FpServer);
await handler.HandleAsync(10UL, 1UL, AlarmPairing(FpServer, 55UL), CancellationToken.None);
@@ -159,10 +171,8 @@ public async Task EntityPairing_StorageMonitor_PublishesStorageMonitorPairedEven
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();
- bus.ClearReceivedCalls();
+ var (handler, _) = CreateHandler(context, bus);
+ var server = await SeedServerAsync(context, FpServer);
await handler.HandleAsync(10UL, 1UL, StorageMonitorPairing(FpServer, 77UL), CancellationToken.None);
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs
index 600fc34b..be030f7c 100644
--- a/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs
@@ -7,7 +7,9 @@
using RustPlusBot.Discord.Notifications;
using RustPlusBot.Features.Pairing.Accounts;
using RustPlusBot.Features.Pairing.Pairing;
+using RustPlusBot.Features.Pairing.Posting;
using RustPlusBot.Features.Pairing.Supervisor;
+using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Persistence;
namespace RustPlusBot.Features.Pairing.Tests;
@@ -28,12 +30,17 @@ public async Task Services_Resolve()
services.AddOptions();
services.AddPairing();
+ // Shadow the Discord-backed poster and Workspace-backed locator with substitutes.
+ services.AddSingleton(Substitute.For());
+ services.AddSingleton(Substitute.For());
+
await using var provider = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true
});
Assert.NotNull(provider.GetRequiredService());
+ Assert.NotNull(provider.GetRequiredService());
await using var scope = provider.CreateAsyncScope();
Assert.NotNull(scope.ServiceProvider.GetRequiredService());
Assert.NotNull(scope.ServiceProvider.GetRequiredService());
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs
new file mode 100644
index 00000000..c181514e
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs
@@ -0,0 +1,310 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using RustPlusBot.Abstractions.Credentials;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Domain.Credentials;
+using RustPlusBot.Features.Pairing.Listening;
+using RustPlusBot.Features.Pairing.Notifications;
+using RustPlusBot.Features.Pairing.Pairing;
+using RustPlusBot.Features.Pairing.Posting;
+using RustPlusBot.Features.Pairing.Rendering;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Localization;
+using RustPlusBot.Persistence;
+using RustPlusBot.Persistence.Credentials;
+using RustPlusBot.Persistence.Servers;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Pairing.Tests;
+
+public sealed class ServerPairingCoordinatorTests
+{
+ private static readonly Guid FpServer = Guid.Parse("11111111-1111-1111-1111-111111111111");
+
+ private static ICredentialProtector PassThrough()
+ {
+ var p = Substitute.For();
+ p.Protect(Arg.Any()).Returns(c => c.Arg());
+ return p;
+ }
+
+ private static PairingNotification ServerPairing(string ip = "1.2.3.4", int port = 28015, ulong steam = 7UL) =>
+ new(PairingKind.Server, "Rustopia", ip, port, steam, "ptoken", FacepunchServerId: FpServer, EntityId: 0UL);
+
+ private static Harness Create(ulong? channelId = 777UL)
+ {
+ var (context, connection) = TestDb.Create();
+
+ var workspace = Substitute.For();
+ workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en");
+
+ var services = new ServiceCollection();
+ services.AddScoped(_ => new ServerService(context));
+ services.AddScoped(_ => new CredentialStore(context, PassThrough()));
+ services.AddScoped(_ => workspace);
+ var provider = services.BuildServiceProvider();
+
+ var locator = Substitute.For();
+ locator.GetChannelIdAsync(Arg.Any(), Arg.Any()).Returns(channelId);
+
+ var poster = Substitute.For();
+ poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(900UL);
+
+ var notifier = Substitute.For();
+ var bus = Substitute.For();
+ var coordinator = new ServerPairingCoordinator(
+ provider.GetRequiredService(), locator, poster,
+ new ServerPairingPromptRenderer(new ResxLocalizer()), notifier, bus,
+ NullLogger.Instance);
+
+ return new Harness(coordinator, context, connection, locator, poster, notifier, bus);
+ }
+
+ [Fact]
+ public async Task Detected_posts_prompt_holds_pending_and_persists_nothing()
+ {
+ var h = Create();
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+
+ await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ await h.Poster.Received(1).EnsureAsync(777UL, null, Arg.Any(),
+ Arg.Any(), Arg.Any());
+ Assert.True(h.Coordinator.HasPending(10UL, "1.2.3.4", 28015));
+ Assert.Empty(await h.Context.RustServers.ToListAsync());
+ Assert.Empty(await h.Context.PlayerCredentials.ToListAsync());
+ await h.Bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Detected_again_while_pending_refreshes_token_and_reensures_without_duplicate()
+ {
+ var h = Create();
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+
+ await h.Coordinator.HandleDetectedAsync(10UL, 1UL, ServerPairing(steam: 1UL), CancellationToken.None);
+ await h.Coordinator.HandleDetectedAsync(10UL, 2UL, ServerPairing(steam: 2UL), CancellationToken.None);
+
+ // Exactly one fresh post; the repeat edits that same message (self-heal) rather than posting a duplicate.
+ await h.Poster.Received(1).EnsureAsync(Arg.Any(), null, Arg.Any(),
+ Arg.Any(), Arg.Any());
+ await h.Poster.Received(1).EnsureAsync(Arg.Any(), 900UL, Arg.Any(),
+ Arg.Any(), Arg.Any());
+
+ // Accepting proves the refreshed pairing (owner 2) won.
+ await h.Coordinator.TryAcceptAsync(10UL, "1.2.3.4", 28015, CancellationToken.None);
+ var server = await h.Context.RustServers.SingleAsync();
+ Assert.Equal(2UL, server.AddedByUserId);
+ var credential = await h.Context.PlayerCredentials.SingleAsync();
+ Assert.Equal(2UL, credential.OwnerUserId);
+ }
+
+ [Fact]
+ public async Task Repeat_detection_reensures_prompt_so_a_deleted_message_self_heals()
+ {
+ var h = Create();
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+
+ // First detection posts the prompt as message 900 (the Create() default).
+ await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ // The prompt was deleted; re-ensuring the known id self-heals into a fresh message 901.
+ h.Poster.EnsureAsync(Arg.Any(), 900UL, Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(901UL);
+
+ await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ // The repeat re-ensures the known message rather than silently keeping a dead prompt pending.
+ await h.Poster.Received(1).EnsureAsync(Arg.Any(), 900UL, Arg.Any(),
+ Arg.Any(), Arg.Any());
+ Assert.True(h.Coordinator.HasPending(10UL, "1.2.3.4", 28015));
+
+ // Pending now tracks the healed id: Accept edits 901, not the stale 900.
+ await h.Coordinator.TryAcceptAsync(10UL, "1.2.3.4", 28015, CancellationToken.None);
+ await h.Poster.Received(1).EnsureAsync(Arg.Any(), 901UL, Arg.Any(),
+ Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Detected_without_setup_channel_notifies_owner_and_drops()
+ {
+ var h = Create(channelId: null);
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+
+ await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ await h.Notifier.Received(1).NotifySetupChannelMissingAsync(10UL, 99UL, Arg.Any());
+ Assert.False(h.Coordinator.HasPending(10UL, "1.2.3.4", 28015));
+ await h.Poster.DidNotReceive().EnsureAsync(Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Accept_persists_publishes_event_once_and_edits_prompt()
+ {
+ var h = Create();
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+ await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ var outcome = await h.Coordinator.TryAcceptAsync(10UL, "1.2.3.4", 28015, CancellationToken.None);
+
+ Assert.Equal(ServerPairingAcceptOutcome.Added, outcome);
+ var server = await h.Context.RustServers.SingleAsync();
+ Assert.Equal("Rustopia", server.Name);
+ Assert.Equal(FpServer, server.FacepunchServerId);
+ var credential = await h.Context.PlayerCredentials.SingleAsync();
+ Assert.Equal(server.Id, credential.RustServerId);
+ Assert.Equal(CredentialStatus.Active, credential.Status);
+ await h.Bus.Received(1).PublishAsync(
+ Arg.Is(e => e.GuildId == 10UL && e.ServerId == server.Id),
+ Arg.Any());
+ await h.Poster.Received(1).EnsureAsync(777UL, 900UL, Arg.Any(),
+ Arg.Any(), Arg.Any());
+ Assert.False(h.Coordinator.HasPending(10UL, "1.2.3.4", 28015));
+ }
+
+ [Fact]
+ public async Task Accept_when_server_already_exists_upserts_credential_without_event()
+ {
+ var h = Create();
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+ await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ // Another path created the same endpoint while the prompt sat unanswered.
+ await new ServerService(h.Context).AddAsync(10UL, 50UL, "Rustopia", "1.2.3.4", 28015);
+
+ var outcome = await h.Coordinator.TryAcceptAsync(10UL, "1.2.3.4", 28015, CancellationToken.None);
+
+ Assert.Equal(ServerPairingAcceptOutcome.AlreadyAdded, outcome);
+ Assert.Single(await h.Context.RustServers.ToListAsync());
+ var credential = await h.Context.PlayerCredentials.SingleAsync();
+ Assert.Equal(CredentialStatus.Standby, credential.Status);
+ await h.Bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Accept_without_pending_returns_expired_and_persists_nothing()
+ {
+ var h = Create();
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+
+ var outcome = await h.Coordinator.TryAcceptAsync(10UL, "1.2.3.4", 28015, CancellationToken.None);
+
+ Assert.Equal(ServerPairingAcceptOutcome.Expired, outcome);
+ Assert.Empty(await h.Context.RustServers.ToListAsync());
+ await h.Bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Concurrent_detections_for_same_endpoint_post_single_prompt()
+ {
+ var h = Create();
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+ var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ h.Poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(_ => gate.Task);
+
+ var first = h.Coordinator.HandleDetectedAsync(10UL, 1UL, ServerPairing(steam: 1UL), CancellationToken.None);
+ var second = h.Coordinator.HandleDetectedAsync(10UL, 2UL, ServerPairing(steam: 2UL), CancellationToken.None);
+ gate.SetResult(900UL);
+ await Task.WhenAll(first, second);
+
+ // The gate serialized the two detections: one fresh post, then a self-healing edit of that same
+ // message — a single prompt, never a duplicate post (without the gate, both would post with a null id).
+ await h.Poster.Received(1).EnsureAsync(Arg.Any(), null, Arg.Any(),
+ Arg.Any(), Arg.Any());
+ await h.Poster.Received(1).EnsureAsync(Arg.Any(), 900UL, Arg.Any(),
+ Arg.Any(), Arg.Any());
+ Assert.True(h.Coordinator.HasPending(10UL, "1.2.3.4", 28015));
+ }
+
+ [Fact]
+ public async Task Detected_with_failed_prompt_post_drops_pending_and_repair_retries()
+ {
+ var h = Create();
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+ h.Poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns((ulong?)null);
+
+ await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ Assert.False(h.Coordinator.HasPending(10UL, "1.2.3.4", 28015));
+ Assert.Empty(await h.Context.RustServers.ToListAsync());
+
+ h.Poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(900UL);
+
+ await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ await h.Poster.Received(2).EnsureAsync(Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(),
+ Arg.Any());
+ Assert.True(h.Coordinator.HasPending(10UL, "1.2.3.4", 28015));
+ }
+
+ [Fact]
+ public async Task Accept_without_setup_channel_still_persists_and_skips_edit()
+ {
+ var h = Create();
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+ await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ h.Locator.GetChannelIdAsync(Arg.Any(), Arg.Any()).Returns((ulong?)null);
+
+ var outcome = await h.Coordinator.TryAcceptAsync(10UL, "1.2.3.4", 28015, CancellationToken.None);
+
+ Assert.Equal(ServerPairingAcceptOutcome.Added, outcome);
+ var server = await h.Context.RustServers.SingleAsync();
+ Assert.Equal("Rustopia", server.Name);
+ var credential = await h.Context.PlayerCredentials.SingleAsync();
+ Assert.Equal(server.Id, credential.RustServerId);
+ Assert.Equal(CredentialStatus.Active, credential.Status);
+ await h.Bus.Received(1).PublishAsync(
+ Arg.Is(e => e.GuildId == 10UL && e.ServerId == server.Id),
+ Arg.Any());
+ await h.Poster.Received(1).EnsureAsync(Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Dismiss_clears_pending_once()
+ {
+ var h = Create();
+ await using var _ = h.Context;
+ await using var __ = h.Connection;
+ await h.Coordinator.HandleDetectedAsync(10UL, 99UL, ServerPairing(), CancellationToken.None);
+
+ Assert.True(h.Coordinator.TryDismiss(10UL, "1.2.3.4", 28015));
+ Assert.False(h.Coordinator.TryDismiss(10UL, "1.2.3.4", 28015));
+ Assert.False(h.Coordinator.HasPending(10UL, "1.2.3.4", 28015));
+ }
+
+ private sealed record Harness(
+ ServerPairingCoordinator Coordinator,
+ BotDbContext Context,
+ Microsoft.Data.Sqlite.SqliteConnection Connection,
+ ISetupChannelLocator Locator,
+ ISetupChannelPoster Poster,
+ IOwnerNotifier Notifier,
+ IEventBus Bus);
+}
diff --git a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingPromptRendererTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingPromptRendererTests.cs
new file mode 100644
index 00000000..b27ac572
--- /dev/null
+++ b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingPromptRendererTests.cs
@@ -0,0 +1,69 @@
+using Discord;
+using RustPlusBot.Features.Pairing.Rendering;
+using RustPlusBot.Localization;
+
+namespace RustPlusBot.Features.Pairing.Tests;
+
+public sealed class ServerPairingPromptRendererTests
+{
+ private static ServerPairingPromptRenderer Create() => new(new ResxLocalizer());
+
+ [Fact]
+ public void RenderPrompt_shows_server_identity_and_carries_endpoint_tail()
+ {
+ var (embed, components) = Create().RenderPrompt("Rustopia", "1.2.3.4", 28015, "en");
+
+ Assert.Equal("New server detected", embed.Title);
+ Assert.Contains("Rustopia", embed.Description, StringComparison.Ordinal);
+ Assert.Contains("1.2.3.4:28015", embed.Description, StringComparison.Ordinal);
+
+ var buttons = components.Components.OfType().SelectMany(r => r.Components)
+ .OfType().ToList();
+ Assert.Contains(buttons, b => b.CustomId == $"{ServerPairingComponentIds.AcceptPrefix}1.2.3.4:28015");
+ Assert.Contains(buttons, b => b.CustomId == $"{ServerPairingComponentIds.DismissPrefix}1.2.3.4:28015");
+ }
+
+ [Fact]
+ public void RenderPrompt_french_uses_french_copy()
+ {
+ var (embed, _) = Create().RenderPrompt("Rustopia", "1.2.3.4", 28015, "fr");
+
+ Assert.Equal("Nouveau serveur détecté", embed.Title);
+ }
+
+ [Fact]
+ public void RenderAdded_has_no_buttons()
+ {
+ var (embed, components) = Create().RenderAdded("Rustopia", "en");
+
+ Assert.Equal("Server added", embed.Title);
+ Assert.Contains("Rustopia", embed.Description, StringComparison.Ordinal);
+ Assert.Empty(components.Components);
+ }
+
+ [Theory]
+ [InlineData("1.2.3.4:28015", "1.2.3.4", 28015)]
+ [InlineData("play.example.com:28083", "play.example.com", 28083)]
+ [InlineData("2001:db8::1:28015", "2001:db8::1", 28015)]
+ public void TryParseTail_round_trips_endpoints(string tail, string expectedIp, int expectedPort)
+ {
+ Assert.True(ServerPairingComponentIds.TryParseTail(tail, out var ip, out var port));
+ Assert.Equal(expectedIp, ip);
+ Assert.Equal(expectedPort, port);
+ Assert.Equal(tail, ServerPairingComponentIds.Tail(ip, port));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("no-port")]
+ [InlineData(":28015")]
+ [InlineData("1.2.3.4:")]
+ [InlineData("1.2.3.4:notaport")]
+ [InlineData("1.2.3.4:0")]
+ [InlineData("1.2.3.4:70000")]
+ public void TryParseTail_rejects_invalid_tails(string? tail)
+ {
+ Assert.False(ServerPairingComponentIds.TryParseTail(tail, out _, out _));
+ }
+}
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs
new file mode 100644
index 00000000..1bd3ce90
--- /dev/null
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs
@@ -0,0 +1,118 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.Servers;
+using RustPlusBot.Domain.Workspace;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Workspace.Tests.Locating;
+
+public sealed class SetupChannelLocatorTests
+{
+ private static (SetupChannelLocator Locator, ServiceProvider Provider, string ConnectionString, IClock Clock)
+ CreateLocator()
+ {
+ var clock = Substitute.For();
+ clock.UtcNow.Returns(DateTimeOffset.UnixEpoch);
+
+ var cs = $"DataSource=setup-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
+ var keepAlive = new SqliteConnection(cs);
+ keepAlive.Open();
+ using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options))
+ {
+ seed.Database.Migrate();
+ }
+
+ var services = new ServiceCollection();
+ services.AddSingleton(keepAlive);
+ services.AddSingleton(clock);
+ services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options));
+ services.AddScoped();
+ var provider = services.BuildServiceProvider();
+
+ var locator = new SetupChannelLocator(provider.GetRequiredService(), clock);
+ return (locator, provider, cs, clock);
+ }
+
+ private static async Task SeedAsync(string connectionString)
+ {
+ await using var context =
+ new BotDbContext(new DbContextOptionsBuilder().UseSqlite(connectionString).Options);
+
+ // The global #setup channel row (RustServerId null) the locator must resolve.
+ context.ProvisionedChannels.Add(new ProvisionedChannel
+ {
+ GuildId = 10UL,
+ RustServerId = null,
+ ChannelKey = WorkspaceChannelKeys.Setup,
+ DiscordChannelId = 777UL,
+ CreatedAt = DateTimeOffset.UnixEpoch,
+ });
+
+ // A server-scoped row under the same key must be skipped (defensive; cannot occur in practice).
+ var server = new RustServer
+ {
+ GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ await context.SaveChangesAsync();
+ context.ProvisionedChannels.Add(new ProvisionedChannel
+ {
+ GuildId = 10UL,
+ RustServerId = server.Id,
+ ChannelKey = WorkspaceChannelKeys.Setup,
+ DiscordChannelId = 888UL,
+ CreatedAt = DateTimeOffset.UnixEpoch,
+ });
+ await context.SaveChangesAsync();
+ }
+
+ [Fact]
+ public async Task GetChannelIdAsync_returns_global_setup_channel()
+ {
+ var (locator, provider, cs, _) = CreateLocator();
+ await using var _p = provider;
+ using var _l = locator;
+ await SeedAsync(cs);
+
+ var id = await locator.GetChannelIdAsync(10UL, CancellationToken.None);
+
+ Assert.Equal(777UL, id);
+ }
+
+ [Fact]
+ public async Task GetChannelIdAsync_returns_null_when_unprovisioned()
+ {
+ var (locator, provider, _, _) = CreateLocator();
+ await using var _p = provider;
+ using var _l = locator;
+
+ var id = await locator.GetChannelIdAsync(10UL, CancellationToken.None);
+
+ Assert.Null(id);
+ }
+
+ [Fact]
+ public async Task GetChannelIdAsync_serves_cached_entries_until_ttl_expires()
+ {
+ var (locator, provider, cs, clock) = CreateLocator();
+ await using var _p = provider;
+ using var _l = locator;
+
+ // First read builds an empty cache.
+ Assert.Null(await locator.GetChannelIdAsync(10UL, CancellationToken.None));
+
+ await SeedAsync(cs);
+
+ // Within the TTL the stale empty cache is served.
+ Assert.Null(await locator.GetChannelIdAsync(10UL, CancellationToken.None));
+
+ // Past the TTL the cache refreshes and finds the row.
+ clock.UtcNow.Returns(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(31));
+ Assert.Equal(777UL, await locator.GetChannelIdAsync(10UL, CancellationToken.None));
+ }
+}
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs
index e56affde..95e684cb 100644
--- a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs
@@ -4,6 +4,7 @@
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Features.Workspace.Locating;
using RustPlusBot.Features.Workspace.Reconciler;
using RustPlusBot.Features.Workspace.Teardown;
using RustPlusBot.Persistence;
@@ -34,4 +35,19 @@ public void ReconcilerAndTeardown_ResolveFromScope()
Assert.NotNull(scope.ServiceProvider.GetRequiredService());
Assert.NotNull(scope.ServiceProvider.GetRequiredService());
}
+
+ [Fact]
+ public void AddWorkspace_registers_channel_locators()
+ {
+ var services = new ServiceCollection();
+ services.AddWorkspace();
+
+ Assert.Contains(services, d => d.ServiceType == typeof(ITeamChatChannelLocator));
+ Assert.Contains(services, d => d.ServiceType == typeof(IEventChannelLocator));
+ Assert.Contains(services, d => d.ServiceType == typeof(IMapChannelLocator));
+ Assert.Contains(services, d => d.ServiceType == typeof(ISwitchChannelLocator));
+ Assert.Contains(services, d => d.ServiceType == typeof(IAlarmChannelLocator));
+ Assert.Contains(services, d => d.ServiceType == typeof(IStorageMonitorChannelLocator));
+ Assert.Contains(services, d => d.ServiceType == typeof(ISetupChannelLocator));
+ }
}
diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs
index 7e051955..477144ed 100644
--- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs
+++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs
@@ -41,6 +41,6 @@ public void English_covers_every_french_key()
[Fact]
public void Catalog_has_expected_key_count()
{
- Assert.Equal(268, EnglishKeys().Count);
+ Assert.Equal(274, EnglishKeys().Count);
}
}