From a79fbca96fae3cb6cdd798deb44a43728c1e14cb Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 02:25:03 +0200 Subject: [PATCH 01/11] feat(workspace): add ISetupChannelLocator for the global #setup channel Co-Authored-By: Claude Fable 5 --- .../Locating/ISetupChannelLocator.cs | 11 ++ .../Locating/SetupChannelLocator.cs | 72 +++++++++++ .../WorkspaceServiceCollectionExtensions.cs | 1 + .../Locating/SetupChannelLocatorTests.cs | 118 ++++++++++++++++++ .../WorkspaceRegistrationTests.cs | 16 +++ 5 files changed, 218 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Locating/ISetupChannelLocator.cs create mode 100644 src/RustPlusBot.Features.Workspace/Locating/SetupChannelLocator.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs 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/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)); + } } From c45cdc0cae1df26307c86ee15ec99895981ee2e3 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 02:34:10 +0200 Subject: [PATCH 02/11] feat(pairing): server-pairing prompt renderer, component ids, localization Co-Authored-By: Claude Fable 5 --- .../Rendering/ServerPairingComponentIds.cs | 50 ++++++++++++++ .../Rendering/ServerPairingPromptRenderer.cs | 51 ++++++++++++++ .../RustPlusBot.Features.Pairing.csproj | 1 + src/RustPlusBot.Localization/Strings.fr.resx | 18 +++++ src/RustPlusBot.Localization/Strings.resx | 18 +++++ .../ServerPairingPromptRendererTests.cs | 69 +++++++++++++++++++ 6 files changed, 207 insertions(+) create mode 100644 src/RustPlusBot.Features.Pairing/Rendering/ServerPairingComponentIds.cs create mode 100644 src/RustPlusBot.Features.Pairing/Rendering/ServerPairingPromptRenderer.cs create mode 100644 tests/RustPlusBot.Features.Pairing.Tests/ServerPairingPromptRendererTests.cs 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.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/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 _)); + } +} From f9ce8b08e9f7cd7c22e7b04cd2b572b832740cdf Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 02:39:51 +0200 Subject: [PATCH 03/11] feat(pairing): setup-channel poster + setup-missing owner notification Co-Authored-By: Claude Fable 5 --- .../Notifications/DiscordOwnerNotifier.cs | 10 +++++++++ .../Notifications/IOwnerNotifier.cs | 6 +++++ .../Posting/DiscordSetupChannelPoster.cs | 22 +++++++++++++++++++ .../Posting/ISetupChannelPoster.cs | 19 ++++++++++++++++ .../DiscordOwnerNotifierTests.cs | 12 ++++++++++ .../Fakes/RecordingOwnerNotifier.cs | 12 ++++++++++ 6 files changed, 81 insertions(+) create mode 100644 src/RustPlusBot.Features.Pairing/Posting/DiscordSetupChannelPoster.cs create mode 100644 src/RustPlusBot.Features.Pairing/Posting/ISetupChannelPoster.cs 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..948bd683 100644 --- a/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs +++ b/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs @@ -8,4 +8,10 @@ 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/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/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; + } } From bc8544611d8d3efeea60611aa81f52d1fe5096e5 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 02:46:53 +0200 Subject: [PATCH 04/11] feat(pairing): ServerPairingCoordinator with in-memory pending + DI wiring Co-Authored-By: Claude Fable 5 --- .../Pairing/IServerPairingCoordinator.cs | 50 +++++ .../Pairing/ServerPairingCoordinator.cs | 154 +++++++++++++ .../PairingServiceCollectionExtensions.cs | 8 + .../PairingRegistrationTests.cs | 7 + .../ServerPairingCoordinatorTests.cs | 202 ++++++++++++++++++ 5 files changed, 421 insertions(+) create mode 100644 src/RustPlusBot.Features.Pairing/Pairing/IServerPairingCoordinator.cs create mode 100644 src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs create mode 100644 tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs 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/ServerPairingCoordinator.cs b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs new file mode 100644 index 00000000..0e249918 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs @@ -0,0 +1,154 @@ +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. +/// 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 +{ + private readonly ConcurrentDictionary<(ulong Guild, string Ip, int Port), Pending> _pending = new(); + + /// 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)); + + /// + public async Task HandleDetectedAsync( + ulong guildId, + ulong ownerUserId, + PairingNotification notification, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + var key = (guildId, notification.Ip, notification.Port); + if (_pending.TryGetValue(key, out var existing)) + { + // Repeat in-game "Pair" press: keep the prompt, refresh the held pairing (latest token wins). + _pending[key] = new Pending(ownerUserId, notification, existing.MessageId); + return; + } + + var channelId = await locator.GetChannelIdAsync(guildId, cancellationToken).ConfigureAwait(false); + if (channelId is not { } channel) + { + 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); + var messageId = await poster.EnsureAsync(channel, null, embed, components, cancellationToken) + .ConfigureAwait(false); + _pending[key] = new Pending(ownerUserId, notification, messageId); + } + + /// + 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 _); + + 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); + + 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/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..5844f25e --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs @@ -0,0 +1,202 @@ +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 sealed record Harness( + ServerPairingCoordinator Coordinator, + BotDbContext Context, + Microsoft.Data.Sqlite.SqliteConnection Connection, + ISetupChannelLocator Locator, + ISetupChannelPoster Poster, + IOwnerNotifier Notifier, + IEventBus Bus); + + 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_without_reposting() + { + 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); + + await h.Poster.Received(1).EnsureAsync(Arg.Any(), Arg.Any(), + 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 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 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)); + } +} From 39f4577a3ac9d98742678c3ab7a452ec40b01574 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 02:57:27 +0200 Subject: [PATCH 05/11] fix(pairing): serialize server-pairing detection to prevent duplicate prompts Co-Authored-By: Claude Fable 5 --- .../Pairing/ServerPairingCoordinator.cs | 58 ++++++++++++------- .../ServerPairingCoordinatorTests.cs | 22 +++++++ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs index 0e249918..fe7c1d3f 100644 --- a/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs +++ b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs @@ -15,7 +15,8 @@ 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. +/// Pending state is in-memory only (mirrors the entity coordinators): lost on restart, re-pair to re-prompt. +/// 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. @@ -30,10 +31,14 @@ internal sealed partial class ServerPairingCoordinator( ServerPairingPromptRenderer renderer, IOwnerNotifier ownerNotifier, IEventBus eventBus, - ILogger logger) : IServerPairingCoordinator + 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(); + /// Gets whether a pairing is pending for the endpoint (test seam). /// The guild id. /// The server host or ip. @@ -49,29 +54,40 @@ public async Task HandleDetectedAsync( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(notification); - var key = (guildId, notification.Ip, notification.Port); - if (_pending.TryGetValue(key, out var existing)) - { - // Repeat in-game "Pair" press: keep the prompt, refresh the held pairing (latest token wins). - _pending[key] = new Pending(ownerUserId, notification, existing.MessageId); - return; - } - var channelId = await locator.GetChannelIdAsync(guildId, cancellationToken).ConfigureAwait(false); - if (channelId is not { } channel) + // 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 { - LogSetupChannelMissing(logger, guildId); - await ownerNotifier.NotifySetupChannelMissingAsync(guildId, ownerUserId, cancellationToken) + var key = (guildId, notification.Ip, notification.Port); + if (_pending.TryGetValue(key, out var existing)) + { + // Repeat in-game "Pair" press: keep the prompt, refresh the held pairing (latest token wins). + _pending[key] = new Pending(ownerUserId, notification, existing.MessageId); + return; + } + + var channelId = await locator.GetChannelIdAsync(guildId, cancellationToken).ConfigureAwait(false); + if (channelId is not { } channel) + { + 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); + var messageId = await poster.EnsureAsync(channel, null, embed, components, cancellationToken) .ConfigureAwait(false); - return; + _pending[key] = new Pending(ownerUserId, notification, messageId); + } + finally + { + _detectGate.Release(); } - - var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); - var (embed, components) = - renderer.RenderPrompt(notification.ServerName, notification.Ip, notification.Port, culture); - var messageId = await poster.EnsureAsync(channel, null, embed, components, cancellationToken) - .ConfigureAwait(false); - _pending[key] = new Pending(ownerUserId, notification, messageId); } /// diff --git a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs index 5844f25e..5be61fbe 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs @@ -187,6 +187,28 @@ public async Task Accept_without_pending_returns_expired_and_persists_nothing() 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); + + await h.Poster.Received(1).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 Dismiss_clears_pending_once() { From 0666e0711aafa7d7a16c89a41577d698e87929c3 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 03:04:34 +0200 Subject: [PATCH 06/11] feat(pairing): gate new-server pairing behind #setup confirmation Co-Authored-By: Claude Fable 5 --- .../Pairing/PairingHandler.cs | 31 +++---- .../PairingHandlerTests.cs | 81 ++++++++++--------- 2 files changed, 62 insertions(+), 50 deletions(-) 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/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs index bce5bc8c..e09a02ce 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,26 @@ 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 +61,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 +121,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 +138,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 +153,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 +170,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); From 5d8b4d6b1b95a18e3f26329e030508d61b12b9fd Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 03:05:48 +0200 Subject: [PATCH 07/11] test(localization): bump catalog count guard for server.prompt keys Co-Authored-By: Claude Fable 5 --- .../StringsResourceParityTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } } From 182575d0ed70456b028dc30b6c3a60c3c5455cf2 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 03:12:06 +0200 Subject: [PATCH 08/11] feat(pairing): Accept/Dismiss component module for server prompts Co-Authored-By: Claude Fable 5 --- .../Modules/ServerPairingComponentModule.cs | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/RustPlusBot.Features.Pairing/Modules/ServerPairingComponentModule.cs 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; + } + } +} From d67e9be14b35b1e98eca905f0df44ab0c3647152 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 03:19:54 +0200 Subject: [PATCH 09/11] style: apply ReformatAndReorder Co-Authored-By: Claude Fable 5 --- .../Notifications/IOwnerNotifier.cs | 4 +++- .../Pairing/ServerPairingCoordinator.cs | 14 +++++++------- .../PairingHandlerTests.cs | 3 ++- .../ServerPairingCoordinatorTests.cs | 18 +++++++++--------- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs b/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs index 948bd683..399e1c4f 100644 --- a/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs +++ b/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs @@ -13,5 +13,7 @@ internal interface IOwnerNotifier /// The guild the pairing belongs to. /// The Discord user to notify. /// A cancellation token. - Task NotifySetupChannelMissingAsync(ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); + Task NotifySetupChannelMissingAsync(ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default); } diff --git a/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs index fe7c1d3f..37407465 100644 --- a/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs +++ b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs @@ -39,13 +39,6 @@ internal sealed partial class ServerPairingCoordinator( /// public void Dispose() => _detectGate.Dispose(); - /// 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)); - /// public async Task HandleDetectedAsync( ulong guildId, @@ -152,6 +145,13 @@ await eventBus.PublishAsync(new ServerRegisteredEvent(guildId, server.Id), cance /// 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(); diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs index e09a02ce..4a893949 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs @@ -25,7 +25,8 @@ private static ICredentialProtector PassThrough() } private static (PairingHandler Handler, IServerPairingCoordinator Coordinator) CreateHandler( - BotDbContext context, IEventBus bus) + BotDbContext context, + IEventBus bus) { var coordinator = Substitute.For(); var handler = new PairingHandler(new ServerService(context), new CredentialStore(context, PassThrough()), diff --git a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs index 5be61fbe..7ddab439 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs @@ -33,15 +33,6 @@ private static ICredentialProtector PassThrough() 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 sealed record Harness( - ServerPairingCoordinator Coordinator, - BotDbContext Context, - Microsoft.Data.Sqlite.SqliteConnection Connection, - ISetupChannelLocator Locator, - ISetupChannelPoster Poster, - IOwnerNotifier Notifier, - IEventBus Bus); - private static Harness Create(ulong? channelId = 777UL) { var (context, connection) = TestDb.Create(); @@ -221,4 +212,13 @@ public async Task Dismiss_clears_pending_once() 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); } From 300db04d012fb5cbe163fd346226fd9f26bb488d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 03:33:09 +0200 Subject: [PATCH 10/11] fix(pairing): drop pairing when the #setup prompt cannot be posted DiscordChannelMessenger.EnsureAsync returns null on any post failure (including a stale channel id from the locator's 30s TTL cache), but HandleDetectedAsync stored the pending pairing regardless. A null-MessageId pending permanently short-circuited every later in-game "Pair" press into the refresh branch, so no prompt ever existed and the endpoint was stuck until restart. Now a failed post logs a warning and drops the pairing so re-pairing in-game retries cleanly. Co-Authored-By: Claude Fable 5 --- .../Pairing/ServerPairingCoordinator.cs | 13 ++++- .../ServerPairingCoordinatorTests.cs | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs index 37407465..ebfa4dfa 100644 --- a/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs +++ b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs @@ -75,7 +75,13 @@ await ownerNotifier.NotifySetupChannelMissingAsync(guildId, ownerUserId, cancell renderer.RenderPrompt(notification.ServerName, notification.Ip, notification.Port, culture); var messageId = await poster.EnsureAsync(channel, null, embed, components, cancellationToken) .ConfigureAwait(false); - _pending[key] = new Pending(ownerUserId, notification, messageId); + if (messageId is not { } mid) + { + LogPromptPostFailed(logger, guildId); + return; + } + + _pending[key] = new Pending(ownerUserId, notification, mid); } finally { @@ -166,5 +172,10 @@ private async Task GetCultureAsync(ulong guildId, CancellationToken ct) 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/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs index 7ddab439..f26f8fc7 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs @@ -200,6 +200,59 @@ await h.Poster.Received(1).EnsureAsync(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() { From 582752570ae2fac262347213c8c834e3b42bc56c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 12 Jul 2026 03:53:52 +0200 Subject: [PATCH 11/11] fix(pairing): re-ensure the #setup prompt on repeat detection so a deleted message self-heals A re-detected pairing refreshed the held token but returned without re-ensuring the prompt. If the original #setup prompt was deleted, every later re-pair kept the pairing pending yet never recreated the prompt, blocking acceptance until a restart cleared pending state. Seed EnsureAsync with the known message id (null on first detection) so an edit self-heals a deleted prompt (repost) and a repeat press never posts a duplicate; the gate still serializes detections. First-post failure still drops pending (retry on re-pair); a transient re-ensure failure keeps the live prompt/pending untouched. Co-Authored-By: Claude Fable 5 --- .../Pairing/ServerPairingCoordinator.cs | 26 ++++++---- .../ServerPairingCoordinatorTests.cs | 47 ++++++++++++++++--- 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs index ebfa4dfa..597e9538 100644 --- a/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs +++ b/src/RustPlusBot.Features.Pairing/Pairing/ServerPairingCoordinator.cs @@ -16,6 +16,7 @@ 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. @@ -54,16 +55,13 @@ public async Task HandleDetectedAsync( try { var key = (guildId, notification.Ip, notification.Port); - if (_pending.TryGetValue(key, out var existing)) - { - // Repeat in-game "Pair" press: keep the prompt, refresh the held pairing (latest token wins). - _pending[key] = new Pending(ownerUserId, notification, existing.MessageId); - return; - } + _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); @@ -73,11 +71,23 @@ await ownerNotifier.NotifySetupChannelMissingAsync(guildId, ownerUserId, cancell var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); var (embed, components) = renderer.RenderPrompt(notification.ServerName, notification.Ip, notification.Port, culture); - var messageId = await poster.EnsureAsync(channel, null, embed, components, cancellationToken) + + // 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) { - LogPromptPostFailed(logger, guildId); + 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; } diff --git a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs index f26f8fc7..c181514e 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs @@ -82,7 +82,7 @@ await h.Poster.Received(1).EnsureAsync(777UL, null, Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any(), - Arg.Any()); + // 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); @@ -103,6 +105,34 @@ await h.Poster.Received(1).EnsureAsync(Arg.Any(), Arg.Any(), 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() { @@ -194,9 +224,12 @@ public async Task Concurrent_detections_for_same_endpoint_post_single_prompt() gate.SetResult(900UL); await Task.WhenAll(first, second); - await h.Poster.Received(1).EnsureAsync(Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any(), - Arg.Any()); + // 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)); }