From 80cdd1fd6f2ea5bf98d75d1391dd7a493feddba3 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 20:33:50 +0200 Subject: [PATCH 01/14] feat(persistence): cascade ConnectionState on RustServer delete Adds FK_ConnectionStates_RustServers_RustServerId with Cascade delete so removing a RustServer automatically removes its orphaned ConnectionState row. Updates three existing tests to seed a real RustServer before inserting ConnectionState (required once FK enforcement applies), and adds a new test that verifies the cascade behaviour end-to-end. Co-Authored-By: Claude Opus 4.8 --- .../ConnectionStateConfiguration.cs | 7 + ...615183313_ServerRemovalCascade.Designer.cs | 474 ++++++++++++++++++ .../20260615183313_ServerRemovalCascade.cs | 30 ++ .../Migrations/BotDbContextModelSnapshot.cs | 10 +- .../Connections/ConnectionStateSchemaTests.cs | 35 +- .../Connections/ConnectionStoreTests.cs | 5 +- 6 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.Designer.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs diff --git a/src/RustPlusBot.Persistence/Configurations/ConnectionStateConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ConnectionStateConfiguration.cs index a413393f..08f33066 100644 --- a/src/RustPlusBot.Persistence/Configurations/ConnectionStateConfiguration.cs +++ b/src/RustPlusBot.Persistence/Configurations/ConnectionStateConfiguration.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using RustPlusBot.Domain.Connections; +using RustPlusBot.Domain.Servers; namespace RustPlusBot.Persistence.Configurations; @@ -10,5 +11,11 @@ public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.HasKey(s => s.RustServerId); + + // Removing a RustServer cascades to its single connection-state row so no orphaned status lingers. + builder.HasOne() + .WithOne() + .HasForeignKey(s => s.RustServerId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.Designer.cs new file mode 100644 index 00000000..28d745ea --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.Designer.cs @@ -0,0 +1,474 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RustPlusBot.Persistence; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + [DbContext(typeof(BotDbContext))] + [Migration("20260615183313_ServerRemovalCascade")] + partial class ServerRemovalCascade + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.HasIndex("ParentId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Guilds"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasColumnType("TEXT"); + + b.HasKey("GuildId", "UserId"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("GlobalName") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("ActiveCredentialId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("RustServerId"); + + b.ToTable("ConnectionStates"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedFcmCredentials") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "OwnerUserId") + .IsUnique(); + + b.ToTable("FcmRegistrations"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedPlayerToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "OwnerUserId") + .IsUnique(); + + b.ToTable("PlayerCredentials"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("PairedEntities"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("EventSubscriptions"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("GuildId"); + + b.ToTable("GuildSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Ip") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordCategoryId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId") + .IsUnique(); + + b.ToTable("ProvisionedCategories"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChannelKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "ChannelKey") + .IsUnique(); + + b.ToTable("ProvisionedChannels"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("DiscordMessageId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "MessageKey") + .IsUnique(); + + b.ToTable("ProvisionedMessages"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + { + b.HasOne("Persistord.Core.Entities.ChannelEntity", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs b/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs new file mode 100644 index 00000000..3479d6f7 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class ServerRemovalCascade : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddForeignKey( + name: "FK_ConnectionStates_RustServers_RustServerId", + table: "ConnectionStates", + column: "RustServerId", + principalTable: "RustServers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ConnectionStates_RustServers_RustServerId", + table: "ConnectionStates"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 0bfd8c27..dc340355 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -125,7 +125,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => { b.Property("RustServerId") - .ValueGeneratedOnAdd() .HasColumnType("TEXT"); b.Property("ActiveCredentialId") @@ -425,6 +424,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => { b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs index fba322d3..908c138e 100644 --- a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using RustPlusBot.Domain.Connections; +using RustPlusBot.Domain.Servers; namespace RustPlusBot.Persistence.Tests.Connections; @@ -12,7 +13,10 @@ public async Task ConnectionState_RoundTrips_StatusAndPlayerCount() await using var _ = context; await using var __ = connection; - var serverId = Guid.NewGuid(); + var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + var serverId = server.Id; context.ConnectionStates.Add(new ConnectionState { RustServerId = serverId, @@ -36,7 +40,10 @@ public async Task ConnectionState_RoundTrips_NullPlayerCount() await using var _ = context; await using var __ = connection; - var serverId = Guid.NewGuid(); + var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + var serverId = server.Id; context.ConnectionStates.Add(new ConnectionState { RustServerId = serverId, @@ -49,4 +56,28 @@ public async Task ConnectionState_RoundTrips_NullPlayerCount() var read = await context.ConnectionStates.SingleAsync(s => s.RustServerId == serverId); Assert.Null(read.PlayerCount); } + + [Fact] + public async Task RemovingServer_CascadeDeletesItsConnectionState() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + context.RustServers.Add(server); + context.ConnectionStates.Add(new ConnectionState + { + RustServerId = server.Id, + GuildId = 10UL, + Status = ConnectionStatus.Connected, + UpdatedAt = DateTimeOffset.UnixEpoch, + }); + await context.SaveChangesAsync(); + + context.RustServers.Remove(server); + await context.SaveChangesAsync(); + + Assert.Empty(await context.ConnectionStates.ToListAsync()); + } } diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs index 4a630797..3a255c82 100644 --- a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs @@ -55,7 +55,10 @@ public async Task UpsertStatus_InsertsThenReportsChangeOnlyWhenDifferent() var (store, context, conn) = Create(); await using var _ = conn; await using var __ = context; - var serverId = Guid.NewGuid(); + var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + var serverId = server.Id; Assert.True(await store.UpsertStatusAsync(10UL, serverId, ConnectionStatus.Connecting, null, null)); Assert.True(await store.UpsertStatusAsync(10UL, serverId, ConnectionStatus.Connected, 5, null)); From 132cd93e781f46bfc5d9376040a1bbb7c48674c2 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 20:39:49 +0200 Subject: [PATCH 02/14] feat(persistence): remove/list a user's credentials across a guild Co-Authored-By: Claude Sonnet 4.6 --- .../Credentials/ICredentialStore.cs | 20 ++++++ .../Credentials/CredentialStore.cs | 29 +++++++++ .../Credentials/CredentialStoreTests.cs | 61 +++++++++++++++++++ 3 files changed, 110 insertions(+) diff --git a/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs b/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs index 09be754b..9d37940d 100644 --- a/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs +++ b/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs @@ -25,4 +25,24 @@ Task UpsertFromPairingAsync( /// A cancellation token. /// The number of stored credentials for that (guild, server). Task CountForServerAsync(ulong guildId, Guid rustServerId, CancellationToken cancellationToken = default); + + /// + /// Removes every player-credential the owner holds across all servers in the guild (full account + /// removal). Returns the distinct server ids whose pool was affected, so callers can re-evaluate + /// those connections. + /// + /// Owning Discord guild snowflake. + /// The Discord user whose credentials are removed. + /// A cancellation token. + /// The distinct server ids whose pool changed. + Task> RemoveForOwnerAsync( + ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); + + /// Lists the distinct server ids the owner currently holds a credential for in the guild. + /// Owning Discord guild snowflake. + /// The Discord user. + /// A cancellation token. + /// The distinct server ids the owner has a credential for. + Task> ListServerIdsForOwnerAsync( + ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); } diff --git a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs index 6a02f12c..6a4d87d9 100644 --- a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs +++ b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs @@ -61,4 +61,33 @@ public Task CountForServerAsync( context.PlayerCredentials .Where(c => c.GuildId == guildId && c.RustServerId == rustServerId) .CountAsync(cancellationToken); + + /// + public async Task> RemoveForOwnerAsync( + ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default) + { + var owned = await context.PlayerCredentials + .Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + if (owned.Count == 0) + { + return []; + } + + var serverIds = owned.Select(c => c.RustServerId).Distinct().ToList(); + context.PlayerCredentials.RemoveRange(owned); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return serverIds; + } + + /// + public async Task> ListServerIdsForOwnerAsync( + ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default) => + await context.PlayerCredentials + .Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId) + .Select(c => c.RustServerId) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); } diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs index a5f4e78e..fbb0ca49 100644 --- a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs @@ -113,4 +113,65 @@ await store.UpsertFromPairingAsync(new StoreCredentialRequest(20UL, serverAGuild Assert.Equal(2, await store.CountForServerAsync(10UL, serverA)); } + + [Fact] + public async Task RemoveForOwner_RemovesOwnersCredsAcrossServers_ReturnsDistinctServerIds_LeavesOthers() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var serverA = await SeedServerAsync(context, 10UL); + var serverB = await SeedServerAsync(context, 10UL, port: 28016); + var store = new CredentialStore(context, PassThroughProtector()); + + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 1UL, 1UL, "t1"), markActive: true); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverB, 1UL, 1UL, "t2"), markActive: true); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 2UL, 2UL, "t3"), markActive: false); + var serverOtherGuild = await SeedServerAsync(context, 20UL); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(20UL, serverOtherGuild, 1UL, 1UL, "t4"), markActive: true); + + var affected = await store.RemoveForOwnerAsync(10UL, 1UL); + + Assert.Equal(2, affected.Count); + Assert.Contains(serverA, affected); + Assert.Contains(serverB, affected); + Assert.DoesNotContain(serverOtherGuild, affected); + var remaining = await context.PlayerCredentials.ToListAsync(); + Assert.Equal(2, remaining.Count); + Assert.Contains(remaining, c => c.GuildId == 10UL && c.OwnerUserId == 2UL); + Assert.Contains(remaining, c => c.GuildId == 20UL && c.OwnerUserId == 1UL); + } + + [Fact] + public async Task RemoveForOwner_WhenNothingOwned_ReturnsEmpty() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + await SeedServerAsync(context, 10UL); + var store = new CredentialStore(context, PassThroughProtector()); + + var affected = await store.RemoveForOwnerAsync(10UL, 999UL); + + Assert.Empty(affected); + } + + [Fact] + public async Task ListServerIdsForOwner_ReturnsDistinctServers() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var serverA = await SeedServerAsync(context, 10UL); + var serverB = await SeedServerAsync(context, 10UL, port: 28016); + var store = new CredentialStore(context, PassThroughProtector()); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 1UL, 1UL, "t1"), markActive: true); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverB, 1UL, 1UL, "t2"), markActive: true); + + var ids = await store.ListServerIdsForOwnerAsync(10UL, 1UL); + + Assert.Equal(2, ids.Count); + Assert.Contains(serverA, ids); + Assert.Contains(serverB, ids); + } } From f5722e1f24c75fde4b9372397b5f0b26db9b5607 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 20:45:13 +0200 Subject: [PATCH 03/14] feat(abstractions): add ServerCredentialsChangedEvent --- .../Events/ServerCredentialsChangedEvent.cs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/RustPlusBot.Abstractions/Events/ServerCredentialsChangedEvent.cs diff --git a/src/RustPlusBot.Abstractions/Events/ServerCredentialsChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/ServerCredentialsChangedEvent.cs new file mode 100644 index 00000000..d0d3d738 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/ServerCredentialsChangedEvent.cs @@ -0,0 +1,9 @@ +namespace RustPlusBot.Abstractions.Events; + +/// +/// Raised when a server's credential pool changed out-of-band (e.g. a user disconnected their account), +/// so the live connection should be re-evaluated and the #info view refreshed. +/// +/// The owning Discord guild snowflake. +/// The affected server. +public sealed record ServerCredentialsChangedEvent(ulong GuildId, Guid ServerId); From 329eb32f0650ba361465432bdf2defe2a3e635e0 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 20:47:42 +0200 Subject: [PATCH 04/14] feat(pairing): expose StopListenerAsync on the supervisor Co-Authored-By: Claude Sonnet 4.6 --- .../Supervisor/IPairingSupervisor.cs | 6 +++++ .../Supervisor/PairingSupervisor.cs | 4 +++ .../Fakes/FakePairingSource.cs | 17 ++++++++++--- .../PairingSupervisorTests.cs | 25 +++++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs b/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs index 7380754b..73c80764 100644 --- a/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs +++ b/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs @@ -22,6 +22,12 @@ Task EnsureListenerAsync( /// A cancellation token. Task StartAllActiveAsync(CancellationToken cancellationToken = default); + /// Stops the listener for (guild, owner), if running. Idempotent. + /// The owning guild snowflake. + /// The Discord user. + /// A task that completes once the listener has stopped. + Task StopListenerAsync(ulong guildId, ulong ownerUserId); + /// Cancels and disposes every listener (called on shutdown). Task StopAllAsync(); } diff --git a/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs b/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs index 09202a7a..d13cab4d 100644 --- a/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs +++ b/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs @@ -106,6 +106,10 @@ await EnsureListenerAsync(registration.GuildId, registration.OwnerUserId, cancel } } + /// + public Task StopListenerAsync(ulong guildId, ulong ownerUserId) => + StopListenerAsync((guildId, ownerUserId)); + /// public async Task StopAllAsync() { diff --git a/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs index 19aa4b7b..0b86b3fa 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs @@ -10,9 +10,14 @@ internal sealed class FakePairingSource : IPairingSource private int _createCount; + private int _disposeCount; + /// How many listeners have been created. public int CreateCount => Volatile.Read(ref _createCount); + /// How many created listeners have been disposed. + public int DisposeCount => Volatile.Read(ref _disposeCount); + /// The last notification callback registered by the supervisor. public Func? LastCallback { get; private set; } @@ -27,14 +32,16 @@ public IPairingListener Create( Interlocked.Increment(ref _createCount); LastCallback = onNotification; var outcome = _outcomes.TryDequeue(out var next) ? next : PairingConnectOutcome.Connected; - return new FakeListener(outcome, () => ConnectedSignal.TrySetResult()); + return new FakeListener(outcome, () => ConnectedSignal.TrySetResult(), + () => Interlocked.Increment(ref _disposeCount)); } /// Enqueues an outcome to be returned by the next listener created. /// The outcome the next listener will return from ConnectAsync. public void EnqueueOutcome(PairingConnectOutcome outcome) => _outcomes.Enqueue(outcome); - private sealed class FakeListener(PairingConnectOutcome outcome, Action onConnected) : IPairingListener + private sealed class FakeListener(PairingConnectOutcome outcome, Action onConnected, Action onDisposed) + : IPairingListener { public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) { @@ -46,6 +53,10 @@ public Task ConnectAsync(TimeSpan timeout, CancellationTo return Task.FromResult(outcome); } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() + { + onDisposed(); + return ValueTask.CompletedTask; + } } } diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs index 334aa34a..50d61ef2 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs @@ -171,6 +171,31 @@ public async Task EnsureListener_CalledTwiceForSameOwner_RestartsListener() Assert.Equal(2, h.Source.CreateCount); } + [Fact] + public async Task StopListener_DisposesTheRunningListener() + { + var source = new FakePairingSource(); + source.EnqueueOutcome(PairingConnectOutcome.Connected); + await using var h = CreateHarness(source); + await SeedRegistrationAsync(h.Provider, 10UL, 99UL); + await h.Supervisor.EnsureListenerAsync(10UL, 99UL); + + await h.Supervisor.StopListenerAsync(10UL, 99UL); + + Assert.Equal(1, h.Source.DisposeCount); + } + +#pragma warning disable S2699 // The implicit assertion is "no exception is thrown". + [Fact] + public async Task StopListener_WhenNotRunning_DoesNotThrow() + { + var source = new FakePairingSource(); + await using var h = CreateHarness(source); + + await h.Supervisor.StopListenerAsync(10UL, 12345UL); + } +#pragma warning restore S2699 + private sealed class Harness : IAsyncDisposable { public required ServiceProvider Provider { get; init; } From 8184de6a26092b93648e6266a5ab15a7f476f574 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 20:55:18 +0200 Subject: [PATCH 05/14] feat(pairing): account disconnect service (disable + drop creds + notify) Co-Authored-By: Claude Opus 4.8 --- .../Accounts/AccountDisconnectService.cs | 69 +++++++++++++ .../Accounts/IAccountDisconnectService.cs | 29 ++++++ .../PairingServiceCollectionExtensions.cs | 2 + .../RustPlusBot.Features.Pairing.csproj | 1 + .../AccountDisconnectServiceTests.cs | 98 +++++++++++++++++++ .../PairingRegistrationTests.cs | 2 + 6 files changed, 201 insertions(+) create mode 100644 src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs create mode 100644 src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs create mode 100644 tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs diff --git a/src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs b/src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs new file mode 100644 index 00000000..111547a8 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs @@ -0,0 +1,69 @@ +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Features.Pairing.Supervisor; +using RustPlusBot.Persistence.Credentials; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Pairing.Accounts; + +/// Default . +/// Stops the user's FCM listener. +/// The FCM registration store. +/// The player-credential pool store. +/// Resolves server names for the preview. +/// Publishes ServerCredentialsChangedEvent per affected server. +internal sealed class AccountDisconnectService( + IPairingSupervisor supervisor, + IFcmRegistrationStore registrations, + ICredentialStore credentials, + IServerService servers, + IEventBus eventBus) : IAccountDisconnectService +{ + /// + public async Task PreviewAsync( + ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default) + { + var registration = await registrations.GetAsync(guildId, ownerUserId, cancellationToken).ConfigureAwait(false); + var serverIds = await credentials.ListServerIdsForOwnerAsync(guildId, ownerUserId, cancellationToken) + .ConfigureAwait(false); + + var names = new List(serverIds.Count); + foreach (var serverId in serverIds) + { + var server = await servers.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (server is not null) + { + names.Add(server.Name); + } + } + + var isConnected = (registration is not null && registration.Status != FcmRegistrationStatus.Disabled) + || serverIds.Count > 0; + return new AccountDisconnectPreview(isConnected, names); + } + + /// + public async Task DisconnectAsync( + ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default) + { + await supervisor.StopListenerAsync(guildId, ownerUserId).ConfigureAwait(false); + + var registration = await registrations.GetAsync(guildId, ownerUserId, cancellationToken).ConfigureAwait(false); + if (registration is not null) + { + await registrations.SetStatusAsync(registration.Id, FcmRegistrationStatus.Disabled, cancellationToken) + .ConfigureAwait(false); + } + + var affected = await credentials.RemoveForOwnerAsync(guildId, ownerUserId, cancellationToken) + .ConfigureAwait(false); + foreach (var serverId in affected) + { + await eventBus.PublishAsync(new ServerCredentialsChangedEvent(guildId, serverId), cancellationToken) + .ConfigureAwait(false); + } + + return affected.Count; + } +} diff --git a/src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs b/src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs new file mode 100644 index 00000000..408bc644 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs @@ -0,0 +1,29 @@ +namespace RustPlusBot.Features.Pairing.Accounts; + +/// Full account disconnect: stop the listener, disable the registration, drop the user's pool credentials. +internal interface IAccountDisconnectService +{ + /// Reads what a disconnect would affect, for the confirmation dialog. + /// The owning guild snowflake. + /// The Discord user. + /// A cancellation token. + /// Whether the user is connected and the names of servers they would be removed from. + Task PreviewAsync( + ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); + + /// + /// Stops the user's FCM listener, marks their registration Disabled, removes all their pool credentials + /// in the guild, and publishes a ServerCredentialsChangedEvent per affected server. + /// + /// The owning guild snowflake. + /// The Discord user. + /// A cancellation token. + /// The number of servers the user was removed from. + Task DisconnectAsync( + ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); +} + +/// What an account disconnect would affect. +/// True if the user has a non-Disabled registration or any pool credential. +/// Names of servers the user holds a credential for. +internal sealed record AccountDisconnectPreview(bool IsConnected, IReadOnlyList AffectedServerNames); diff --git a/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs index ba0da9e3..409d8aae 100644 --- a/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Discord; +using RustPlusBot.Features.Pairing.Accounts; using RustPlusBot.Features.Pairing.Hosting; using RustPlusBot.Features.Pairing.Listening; using RustPlusBot.Features.Pairing.Notifications; @@ -22,6 +23,7 @@ public static IServiceCollection AddPairing(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); // Contribute this assembly's interaction modules to the Discord layer. services.AddSingleton(new InteractionModuleAssembly(typeof(PairingServiceCollectionExtensions).Assembly)); diff --git a/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj b/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj index b06ad5f3..9bb67764 100644 --- a/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj +++ b/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj @@ -2,6 +2,7 @@ + diff --git a/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs new file mode 100644 index 00000000..04039c0d --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs @@ -0,0 +1,98 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Pairing.Accounts; +using RustPlusBot.Features.Pairing.Supervisor; +using RustPlusBot.Persistence.Credentials; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Pairing.Tests; + +public sealed class AccountDisconnectServiceTests +{ + private static readonly string[] RustopiaNames = ["Rustopia"]; + + private static (AccountDisconnectService Sut, IPairingSupervisor Sup, IFcmRegistrationStore Regs, + ICredentialStore Creds, IServerService Servers, IEventBus Bus) Create() + { + var sup = Substitute.For(); + var regs = Substitute.For(); + var creds = Substitute.For(); + var servers = Substitute.For(); + var bus = Substitute.For(); + return (new AccountDisconnectService(sup, regs, creds, servers, bus), sup, regs, creds, servers, bus); + } + + [Fact] + public async Task Disconnect_StopsListener_DisablesRegistration_RemovesCreds_PublishesPerServer() + { + var (sut, sup, regs, creds, _, bus) = Create(); + var regId = Guid.NewGuid(); + var s1 = Guid.NewGuid(); + var s2 = Guid.NewGuid(); + regs.GetAsync(10UL, 99UL, Arg.Any()) + .Returns(new FcmRegistration { Id = regId, GuildId = 10UL, OwnerUserId = 99UL }); + creds.RemoveForOwnerAsync(10UL, 99UL, Arg.Any()) + .Returns(new List { s1, s2 }); + + var count = await sut.DisconnectAsync(10UL, 99UL); + + Assert.Equal(2, count); + await sup.Received(1).StopListenerAsync(10UL, 99UL); + await regs.Received(1).SetStatusAsync(regId, FcmRegistrationStatus.Disabled, Arg.Any()); + await bus.Received(1).PublishAsync( + Arg.Is(e => e.GuildId == 10UL && e.ServerId == s1), + Arg.Any()); + await bus.Received(1).PublishAsync( + Arg.Is(e => e.ServerId == s2), Arg.Any()); + } + + [Fact] + public async Task Disconnect_WhenNothingStored_StopsListener_NoEvents_ReturnsZero() + { + var (sut, sup, regs, creds, _, bus) = Create(); + regs.GetAsync(10UL, 99UL, Arg.Any()).Returns((FcmRegistration?)null); + creds.RemoveForOwnerAsync(10UL, 99UL, Arg.Any()).Returns(new List()); + + var count = await sut.DisconnectAsync(10UL, 99UL); + + Assert.Equal(0, count); + await sup.Received(1).StopListenerAsync(10UL, 99UL); + await regs.DidNotReceive().SetStatusAsync(Arg.Any(), Arg.Any(), + Arg.Any()); + await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Preview_ReportsConnectedAndServerNames() + { + var (sut, _, regs, creds, servers, _) = Create(); + var s1 = Guid.NewGuid(); + regs.GetAsync(10UL, 99UL, Arg.Any()) + .Returns(new FcmRegistration { Id = Guid.NewGuid(), GuildId = 10UL, OwnerUserId = 99UL }); + creds.ListServerIdsForOwnerAsync(10UL, 99UL, Arg.Any()) + .Returns(new List { s1 }); + servers.GetAsync(10UL, s1, Arg.Any()) + .Returns(new RustServer { Id = s1, GuildId = 10UL, Name = "Rustopia", Ip = "1.1.1.1", Port = 28015 }); + + var preview = await sut.PreviewAsync(10UL, 99UL); + + Assert.True(preview.IsConnected); + Assert.Equal(RustopiaNames, preview.AffectedServerNames); + } + + [Fact] + public async Task Preview_WhenNothing_ReportsNotConnected() + { + var (sut, _, regs, creds, _, _) = Create(); + regs.GetAsync(10UL, 99UL, Arg.Any()).Returns((FcmRegistration?)null); + creds.ListServerIdsForOwnerAsync(10UL, 99UL, Arg.Any()).Returns(new List()); + + var preview = await sut.PreviewAsync(10UL, 99UL); + + Assert.False(preview.IsConnected); + Assert.Empty(preview.AffectedServerNames); + } +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs index 5da9d524..730cca0e 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs @@ -6,6 +6,7 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Discord.Notifications; using RustPlusBot.Features.Pairing; +using RustPlusBot.Features.Pairing.Accounts; using RustPlusBot.Features.Pairing.Pairing; using RustPlusBot.Features.Pairing.Supervisor; using RustPlusBot.Persistence; @@ -36,5 +37,6 @@ public async Task Services_Resolve() Assert.NotNull(provider.GetRequiredService()); await using var scope = provider.CreateAsyncScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } } From f52914d2d956d0028f47ecbace5147dfa96aabd3 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 21:02:06 +0200 Subject: [PATCH 06/14] feat(workspace): public IServerWorkspaceRemover seam --- .../Teardown/IServerWorkspaceRemover.cs | 12 ++++++++++++ .../Teardown/WorkspaceTeardownService.cs | 2 +- .../WorkspaceServiceCollectionExtensions.cs | 1 + .../WorkspaceRegistrationTests.cs | 1 + 4 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 src/RustPlusBot.Features.Workspace/Teardown/IServerWorkspaceRemover.cs diff --git a/src/RustPlusBot.Features.Workspace/Teardown/IServerWorkspaceRemover.cs b/src/RustPlusBot.Features.Workspace/Teardown/IServerWorkspaceRemover.cs new file mode 100644 index 00000000..cff6a231 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Teardown/IServerWorkspaceRemover.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Workspace.Teardown; + +/// Public seam to remove one server's provisioned Discord resources (used by the Connections removal flow). +public interface IServerWorkspaceRemover +{ + /// Deletes one server's category, channels, and provisioning records. + /// The guild. + /// The server to remove. + /// A cancellation token. + /// A task. + Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs index 0fe5d9fe..f2e358d5 100644 --- a/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs +++ b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs @@ -11,7 +11,7 @@ namespace RustPlusBot.Features.Workspace.Teardown; internal sealed class WorkspaceTeardownService( IWorkspaceGateway gateway, IWorkspaceStore store, - IProvisioningLock provisioningLock) : IWorkspaceTeardownService + IProvisioningLock provisioningLock) : IWorkspaceTeardownService, IServerWorkspaceRemover { /// public async Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index ff060634..fb61bf6e 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -44,6 +44,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) // Reconciler + teardown (scoped). services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Options (Host binds the "Workspace" section; default = danger commands off). services.AddOptions(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs index de321767..159c113c 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs @@ -30,5 +30,6 @@ public void ReconcilerAndTeardown_ResolveFromScope() Assert.NotNull(scope.ServiceProvider.GetRequiredService()); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } } From 39733534bbedb747369ba42e824ec64c567bde7a Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 21:06:22 +0200 Subject: [PATCH 07/14] feat(connections): server removal orchestrator Co-Authored-By: Claude Sonnet 4.6 --- .../ConnectionServiceCollectionExtensions.cs | 2 + .../Removal/IServerRemovalService.cs | 12 +++++ .../Removal/ServerRemovalService.cs | 25 ++++++++++ .../ConnectionRegistrationTests.cs | 4 ++ ...tPlusBot.Features.Connections.Tests.csproj | 1 + .../ServerRemovalServiceTests.cs | 49 +++++++++++++++++++ 6 files changed, 93 insertions(+) create mode 100644 src/RustPlusBot.Features.Connections/Removal/IServerRemovalService.cs create mode 100644 src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs create mode 100644 tests/RustPlusBot.Features.Connections.Tests/ServerRemovalServiceTests.cs diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs index b82a2093..bd19e028 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using RustPlusBot.Discord; using RustPlusBot.Features.Connections.Hosting; using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Connections.Removal; using RustPlusBot.Features.Connections.Supervisor; namespace RustPlusBot.Features.Connections; @@ -18,6 +19,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); // Contribute this assembly's interaction modules to the Discord layer. services.AddSingleton(new InteractionModuleAssembly(typeof(ConnectionServiceCollectionExtensions).Assembly)); diff --git a/src/RustPlusBot.Features.Connections/Removal/IServerRemovalService.cs b/src/RustPlusBot.Features.Connections/Removal/IServerRemovalService.cs new file mode 100644 index 00000000..c9d6a712 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Removal/IServerRemovalService.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Connections.Removal; + +/// Orchestrates removing a server: stop the socket, delete the row (cascades), tear down the workspace. +internal interface IServerRemovalService +{ + /// Removes a server end-to-end. Returns true if a server row was deleted. + /// The owning guild snowflake. + /// The server to remove. + /// A cancellation token. + /// True if a matching server row was removed. + Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs b/src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs new file mode 100644 index 00000000..923ac14a --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs @@ -0,0 +1,25 @@ +using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Workspace.Teardown; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Connections.Removal; + +/// Default . Order matters: stop the socket before deleting the row, +/// so no late status write re-inserts a connection-state row against a deleted server (FK violation). +/// Stops the live socket. +/// Deletes the RustServer (cascades credentials + connection state). +/// Tears down the server's Discord category/channels/records. +internal sealed class ServerRemovalService( + IConnectionSupervisor supervisor, + IServerService servers, + IServerWorkspaceRemover workspace) : IServerRemovalService +{ + /// + public async Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) + { + await supervisor.StopAsync(guildId, serverId).ConfigureAwait(false); + var removed = await servers.RemoveAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + await workspace.RemoveServerAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return removed; + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs index 280ab2df..0a12d577 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs @@ -6,7 +6,9 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Discord.Notifications; using RustPlusBot.Features.Connections; +using RustPlusBot.Features.Connections.Removal; using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Workspace.Teardown; using RustPlusBot.Persistence; using RustPlusBot.Persistence.Connections; @@ -26,6 +28,7 @@ public async Task Services_Resolve() services.AddLogging(); services.AddBotPersistence("DataSource=:memory:"); services.AddOptions(); + services.AddSingleton(Substitute.For()); services.AddConnections(); await using var provider = services.BuildServiceProvider(new ServiceProviderOptions @@ -36,5 +39,6 @@ public async Task Services_Resolve() 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.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj b/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj index 7f286737..5586b02b 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj +++ b/tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/RustPlusBot.Features.Connections.Tests/ServerRemovalServiceTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerRemovalServiceTests.cs new file mode 100644 index 00000000..87864095 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerRemovalServiceTests.cs @@ -0,0 +1,49 @@ +using NSubstitute; +using RustPlusBot.Features.Connections.Removal; +using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Workspace.Teardown; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class ServerRemovalServiceTests +{ + [Fact] + public async Task RemoveServer_Stops_Deletes_TearsDown_InOrder() + { + var serverId = Guid.NewGuid(); + var supervisor = Substitute.For(); + var servers = Substitute.For(); + servers.RemoveAsync(10UL, serverId, Arg.Any()).Returns(true); + var workspace = Substitute.For(); + var sut = new ServerRemovalService(supervisor, servers, workspace); + + var removed = await sut.RemoveServerAsync(10UL, serverId); + + Assert.True(removed); +#pragma warning disable VSTHRD110 // Received.InOrder requires unawaited calls inside its synchronous ordering lambda — this is the NSubstitute-prescribed pattern. + Received.InOrder(() => + { + supervisor.StopAsync(10UL, serverId); + servers.RemoveAsync(10UL, serverId, Arg.Any()); + workspace.RemoveServerAsync(10UL, serverId, Arg.Any()); + }); +#pragma warning restore VSTHRD110 + } + + [Fact] + public async Task RemoveServer_WhenServerAbsent_ReturnsFalse_StillTearsDown() + { + var serverId = Guid.NewGuid(); + var supervisor = Substitute.For(); + var servers = Substitute.For(); + servers.RemoveAsync(10UL, serverId, Arg.Any()).Returns(false); + var workspace = Substitute.For(); + var sut = new ServerRemovalService(supervisor, servers, workspace); + + var removed = await sut.RemoveServerAsync(10UL, serverId); + + Assert.False(removed); + await workspace.Received(1).RemoveServerAsync(10UL, serverId, Arg.Any()); + } +} From 1da31aeede3d50cd2f3c1bc6f6cc553cd4930e21 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 21:15:35 +0200 Subject: [PATCH 08/14] feat(connections): failover on ServerCredentialsChangedEvent Co-Authored-By: Claude Sonnet 4.6 --- .../Hosting/ConnectionHostedService.cs | 34 ++++++++++++++----- .../ConnectionHostedServiceTests.cs | 24 +++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs b/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs index 8c4fcefb..bd0dd949 100644 --- a/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs +++ b/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs @@ -50,19 +50,17 @@ public async Task StopAsync(CancellationToken cancellationToken) private async Task RunAsync(CancellationToken cancellationToken) { - // Subscription is registered when this loop first awaits the bus, after StartAllAsync completes. + // Subscriptions register when each loop first awaits the bus, after StartAllAsync completes. // The in-process bus does not replay, so events published before this point are not delivered. Safe: - // the server-registered event is only raised by the FCM pairing flow at runtime, long after startup. + // both events are only raised at runtime, long after startup. try { await supervisor.StartAllAsync(cancellationToken).ConfigureAwait(false); - await foreach (var registered in eventBus.SubscribeAsync(cancellationToken) - .ConfigureAwait(false)) - { - await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId, cancellationToken) - .ConfigureAwait(false); - } + await Task.WhenAll( + ConsumeRegisteredAsync(cancellationToken), + ConsumeCredentialsChangedAsync(cancellationToken)) + .ConfigureAwait(false); } catch (OperationCanceledException) { @@ -76,6 +74,26 @@ await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId, #pragma warning restore CA1031 } + private async Task ConsumeRegisteredAsync(CancellationToken cancellationToken) + { + await foreach (var registered in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId, cancellationToken) + .ConfigureAwait(false); + } + } + + private async Task ConsumeCredentialsChangedAsync(CancellationToken cancellationToken) + { + await foreach (var changed in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await supervisor.EnsureConnectionAsync(changed.GuildId, changed.ServerId, cancellationToken) + .ConfigureAwait(false); + } + } + [LoggerMessage(Level = LogLevel.Error, Message = "Connection hosted-service loop faulted.")] private static partial void LogLoopFaulted(ILogger logger, Exception exception); } diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs index afe26086..df315f6b 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs @@ -35,4 +35,28 @@ public async Task Startup_CallsStartAll_And_ServerRegistered_EnsuresAConnection( await service.StopAsync(default); await supervisor.Received(1).StopAllAsync(); } + + [Fact] + public async Task ServerCredentialsChanged_EnsuresAConnection() + { + var supervisor = Substitute.For(); + var bus = new InMemoryEventBus(); + var service = new ConnectionHostedService(supervisor, bus, NullLogger.Instance); + + await service.StartAsync(default); + + var serverId = Guid.NewGuid(); + var deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline + && !supervisor.ReceivedCalls().Any(c => + c.GetMethodInfo().Name == nameof(IConnectionSupervisor.EnsureConnectionAsync))) + { + await bus.PublishAsync(new ServerCredentialsChangedEvent(10UL, serverId)); + await Task.Delay(20); + } + + await supervisor.Received().EnsureConnectionAsync(10UL, serverId, Arg.Any()); + + await service.StopAsync(default); + } } From 0555754ba960dbfc0c9b03fa131629e10bed78bf Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 21:19:20 +0200 Subject: [PATCH 09/14] feat(workspace): refresh #info on ServerCredentialsChangedEvent Co-Authored-By: Claude Sonnet 4.6 --- .../Hosting/WorkspaceHostedService.cs | 30 ++++++++++++++++++- .../Hosting/WorkspaceConnectionStatusTests.cs | 30 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs index db62b4c8..80fc3888 100644 --- a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs +++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs @@ -21,6 +21,7 @@ internal sealed class WorkspaceHostedService( { private readonly CancellationTokenSource _cts = new(); private Task? _connectionStatusLoop; + private Task? _serverCredentialsLoop; private Task? _serverRegisteredLoop; private bool _startupDone; @@ -34,6 +35,7 @@ public Task StartAsync(CancellationToken cancellationToken) client.ChannelDestroyed += OnChannelDestroyedAsync; _serverRegisteredLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None); _connectionStatusLoop = Task.Run(() => ConsumeConnectionStatusAsync(_cts.Token), CancellationToken.None); + _serverCredentialsLoop = Task.Run(() => ConsumeServerCredentialsAsync(_cts.Token), CancellationToken.None); return Task.CompletedTask; } @@ -45,7 +47,7 @@ public async Task StopAsync(CancellationToken cancellationToken) await _cts.CancelAsync().ConfigureAwait(false); foreach (var loop in new[] { - _serverRegisteredLoop, _connectionStatusLoop + _serverRegisteredLoop, _connectionStatusLoop, _serverCredentialsLoop }) { if (loop is null) @@ -136,6 +138,32 @@ await reconciler.ReconcileServerAsync(changed.GuildId, changed.ServerId, cancell } } + private async Task ConsumeServerCredentialsAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var changed in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.ReconcileServerAsync(changed.GuildId, changed.ServerId, cancellationToken) + .ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // Shutting down. + } + catch (Exception ex) // Broad catch is intentional: a faulting consumer must not crash the host. + { + logger.LogError(ex, "ServerCredentialsChanged consumer faulted."); + } + } + private async Task ConsumeServerRegisteredAsync(CancellationToken cancellationToken) { // Subscription is registered when this loop first calls SubscribeAsync; the in-process bus does diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs index 5ae3d204..b7570504 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs @@ -40,4 +40,34 @@ public async Task ConnectionStatusChanged_ReconcilesThatServer() await service.StopAsync(default); } + + [Fact] + public async Task ServerCredentialsChanged_ReconcilesThatServer() + { + var reconciler = Substitute.For(); + var services = new ServiceCollection(); + services.AddScoped(_ => reconciler); + await using var provider = services.BuildServiceProvider(); + + var bus = new InMemoryEventBus(); + var client = new DiscordSocketClient(); + var service = new WorkspaceHostedService(client, bus, + provider.GetRequiredService(), + NullLogger.Instance); + + await service.StartAsync(default); + var serverId = Guid.NewGuid(); + var deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline + && !reconciler.ReceivedCalls().Any(c => + c.GetMethodInfo().Name == nameof(IWorkspaceReconciler.ReconcileServerAsync))) + { + await bus.PublishAsync(new ServerCredentialsChangedEvent(10UL, serverId)); + await Task.Delay(20); + } + + await reconciler.Received().ReconcileServerAsync(10UL, serverId, Arg.Any()); + + await service.StopAsync(default); + } } From 9f3d500a5d6e1e5fd129a141ab1f6082bd51e006 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 21:22:58 +0200 Subject: [PATCH 10/14] feat(workspace): render disconnect + remove-server buttons (EN/FR) Co-Authored-By: Claude Sonnet 4.6 --- .../Localization/LocalizationCatalog.cs | 4 +++ .../Messages/ServerInfoMessageRenderer.cs | 11 ++++-- .../Messages/SetupMessageRenderer.cs | 4 +++ .../WorkspaceComponentIds.cs | 6 ++++ .../Messages/RendererTests.cs | 36 +++++++++++++++++++ 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs index 408c7630..65789731 100644 --- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs @@ -25,6 +25,8 @@ internal sealed class LocalizationCatalog ["setup.body"] = "Click **Connect account** below and paste your Rust+ FCM credentials. Then pair a server in-game and its channels appear automatically.", ["setup.connect.button"] = "Connect account", + ["setup.disconnect.button"] = "Disconnect account", + ["server.info.remove.button"] = "Remove server", ["settings.title"] = "Settings", ["settings.body"] = "Configure the bot for this server.", ["settings.language.label"] = "Language", @@ -54,6 +56,8 @@ internal sealed class LocalizationCatalog ["setup.body"] = "Cliquez sur **Connecter le compte** ci-dessous et collez vos identifiants FCM Rust+. Appairez ensuite un serveur en jeu et ses salons apparaitront automatiquement.", ["setup.connect.button"] = "Connecter le compte", + ["setup.disconnect.button"] = "Déconnecter le compte", + ["server.info.remove.button"] = "Supprimer le serveur", ["settings.title"] = "Parametres", ["settings.body"] = "Configurez le bot pour ce serveur.", ["settings.language.label"] = "Langue", diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs index bde68d28..2bdda2ef 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs @@ -65,7 +65,7 @@ public async ValueTask RenderAsync(MessageRenderContext context, .Where(c => c.Status != CredentialStatus.Invalid) .Take(SelectMenuBuilder.MaxOptionCount) // Discord hard-limits a select menu to 25 options. .ToList(); - MessageComponent? components = null; + var builder = new ComponentBuilder(); if (eligible.Count > 0) { var select = new SelectMenuBuilder() @@ -77,10 +77,15 @@ public async ValueTask RenderAsync(MessageRenderContext context, select.AddOption(label, credential.Id.ToString(), isDefault: credential.Id == active?.Id); } - components = new ComponentBuilder().WithSelectMenu(select).Build(); + builder.WithSelectMenu(select); } - return new MessagePayload(null, embed.Build(), components); + builder.WithButton( + localizer.Get("server.info.remove.button", context.Culture), + $"{WorkspaceComponentIds.ServerInfoRemovePrefix}{serverId}", + ButtonStyle.Danger); + + return new MessagePayload(null, embed.Build(), builder.Build()); } private static string Glyph(ConnectionStatus status) => status switch diff --git a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs index 8b0c2843..24bdf22b 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs @@ -26,6 +26,10 @@ public ValueTask RenderAsync(MessageRenderContext context, Cance localizer.Get("setup.connect.button", context.Culture), WorkspaceComponentIds.ConnectAccount, ButtonStyle.Primary) + .WithButton( + localizer.Get("setup.disconnect.button", context.Culture), + WorkspaceComponentIds.DisconnectAccount, + ButtonStyle.Danger) .Build(); return ValueTask.FromResult(new MessagePayload(null, embed, components)); } diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs index e86a8228..cfba54dd 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs @@ -11,4 +11,10 @@ public static class WorkspaceComponentIds /// Prefix for the #info "swap active player" select; the server id is appended. Handled by Connections. public const string ServerInfoSwapPrefix = "workspace:info:swap:"; + + /// The #setup "Disconnect account" button; handled by the Pairing feature. + public const string DisconnectAccount = "workspace:setup:disconnect"; + + /// Prefix for the #info "remove server" button; the server id is appended. Handled by Connections. + public const string ServerInfoRemovePrefix = "workspace:info:remove:"; } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index d7d341a2..891a72c4 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -182,4 +182,40 @@ public async Task Setup_HasConnectAccountButton() .SelectMany(r => r.Components).OfType(); Assert.Contains(buttons, b => b.CustomId == "workspace:setup:connect"); } + + [Fact] + public async Task Setup_HasDisconnectAccountButton() + { + var renderer = new SetupMessageRenderer(Loc); + + var payload = await renderer.RenderAsync(Global, default); + + var buttons = payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Contains(buttons, b => b.CustomId == "workspace:setup:disconnect"); + } + + [Fact] + public async Task ServerInfo_HasRemoveServerButton() + { + var serverId = Guid.NewGuid(); + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.2.3.4", Port = 28015 }); + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, GuildId = 1, Status = ConnectionStatus.NoCredentials + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns(new List()); + var renderer = new ServerInfoMessageRenderer(servers, connections, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var buttons = payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Contains(buttons, b => b.CustomId == $"workspace:info:remove:{serverId}"); + } } From cd10f821e5f8a9cf62b154022a3f4ceaa46a4c4b Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 21:28:13 +0200 Subject: [PATCH 11/14] feat(pairing): #setup disconnect-account button with confirm --- .../Modules/CredentialModule.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs index 03ace00e..48fef7ff 100644 --- a/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs +++ b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs @@ -1,5 +1,7 @@ +using Discord; using Discord.Interactions; using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Pairing.Accounts; using RustPlusBot.Features.Pairing.Listening; using RustPlusBot.Features.Pairing.Supervisor; using RustPlusBot.Features.Pairing.Validation; @@ -68,4 +70,71 @@ await FollowupAsync( await FollowupAsync(message, ephemeral: true).ConfigureAwait(false); } } + + private const string DisconnectConfirmId = "pairing:account:disconnect:confirm"; + private const string DisconnectCancelId = "pairing:account:disconnect:cancel"; + + /// Opens an ephemeral confirmation listing the servers a disconnect would affect. + [ComponentInteraction(WorkspaceComponentIds.DisconnectAccount)] + public async Task DisconnectPromptAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var disconnect = scope.ServiceProvider.GetRequiredService(); + var preview = await disconnect.PreviewAsync(Context.Guild.Id, Context.User.Id).ConfigureAwait(false); + if (!preview.IsConnected) + { + await FollowupAsync("You're not connected.", ephemeral: true).ConfigureAwait(false); + return; + } + + var servers = preview.AffectedServerNames.Count > 0 + ? string.Join(", ", preview.AffectedServerNames) + : "no servers yet"; + var components = new ComponentBuilder() + .WithButton("Disconnect", DisconnectConfirmId, ButtonStyle.Danger) + .WithButton("Cancel", DisconnectCancelId, ButtonStyle.Secondary) + .Build(); + await FollowupAsync( + $"This disconnects your account and removes your credentials from: {servers}. Continue?", + ephemeral: true, components: components) + .ConfigureAwait(false); + } + } + + /// Performs the disconnect after confirmation. + [ComponentInteraction(DisconnectConfirmId)] + public async Task DisconnectConfirmAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var disconnect = scope.ServiceProvider.GetRequiredService(); + var count = await disconnect.DisconnectAsync(Context.Guild.Id, Context.User.Id).ConfigureAwait(false); + await FollowupAsync($"Account disconnected. Removed from {count} server(s).", ephemeral: true) + .ConfigureAwait(false); + } + } + + /// Cancels the disconnect. + [ComponentInteraction(DisconnectCancelId)] + public async Task DisconnectCancelAsync() => + await RespondAsync("Cancelled.", ephemeral: true).ConfigureAwait(false); } From ac7ec7af7e963a7b70f42dd48c21051019588e49 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 21:33:13 +0200 Subject: [PATCH 12/14] feat(connections): #info remove-server button with confirm --- .../Modules/ServerRemovalModule.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/RustPlusBot.Features.Connections/Modules/ServerRemovalModule.cs diff --git a/src/RustPlusBot.Features.Connections/Modules/ServerRemovalModule.cs b/src/RustPlusBot.Features.Connections/Modules/ServerRemovalModule.cs new file mode 100644 index 00000000..c89131b8 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Modules/ServerRemovalModule.cs @@ -0,0 +1,79 @@ +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Connections.Removal; +using RustPlusBot.Features.Workspace; + +namespace RustPlusBot.Features.Connections.Modules; + +/// Handles the #info "Remove server" button and its confirmation (ManageGuild). +/// Creates a short-lived DI scope per interaction. +public sealed class ServerRemovalModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + private const string RemoveConfirmPrefix = "connections:server:remove:confirm:"; + private const string RemoveCancelId = "connections:server:remove:cancel"; + + /// Opens an ephemeral confirmation for removing the server. + /// The server id captured from the custom id. + [ComponentInteraction(WorkspaceComponentIds.ServerInfoRemovePrefix + "*")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task RemovePromptAsync(string serverIdRaw) + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + if (!Guid.TryParse(serverIdRaw, out var serverId)) + { + await RespondAsync("That selection wasn't valid.", ephemeral: true).ConfigureAwait(false); + return; + } + + var components = new ComponentBuilder() + .WithButton("Remove server", $"{RemoveConfirmPrefix}{serverId}", ButtonStyle.Danger) + .WithButton("Cancel", RemoveCancelId, ButtonStyle.Secondary) + .Build(); + await RespondAsync( + "This permanently deletes the category, all channels, and stored credentials for this server. Continue?", + ephemeral: true, components: components) + .ConfigureAwait(false); + } + + /// Removes the server after confirmation. + /// The server id captured from the custom id. + [ComponentInteraction(RemoveConfirmPrefix + "*")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task RemoveConfirmAsync(string serverIdRaw) + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + if (!Guid.TryParse(serverIdRaw, out var serverId)) + { + await RespondAsync("That selection wasn't valid.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var removal = scope.ServiceProvider.GetRequiredService(); + var removed = await removal.RemoveServerAsync(Context.Guild.Id, serverId).ConfigureAwait(false); + await FollowupAsync(removed ? "Server removed." : "That server no longer exists.", ephemeral: true) + .ConfigureAwait(false); + } + } + + /// Cancels the removal. + [ComponentInteraction(RemoveCancelId)] + public async Task RemoveCancelAsync() => + await RespondAsync("Cancelled.", ephemeral: true).ConfigureAwait(false); +} From 5d470f6cba53e6fae9e792742ec0f24a95eb5126 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 21:37:26 +0200 Subject: [PATCH 13/14] style: jb reformat for 1b-iii --- .../Credentials/ICredentialStore.cs | 8 +++-- .../Removal/ServerRemovalService.cs | 4 ++- .../Accounts/AccountDisconnectService.cs | 8 +++-- .../Accounts/IAccountDisconnectService.cs | 8 +++-- .../Modules/CredentialModule.cs | 6 ++-- .../Credentials/CredentialStore.cs | 8 +++-- .../AccountDisconnectServiceTests.cs | 29 +++++++++++++++---- .../Messages/RendererTests.cs | 9 +++++- .../Connections/ConnectionStateSchemaTests.cs | 15 ++++++++-- .../Connections/ConnectionStoreTests.cs | 5 +++- .../Credentials/CredentialStoreTests.cs | 6 ++-- 11 files changed, 82 insertions(+), 24 deletions(-) diff --git a/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs b/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs index 9d37940d..b6b09d3b 100644 --- a/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs +++ b/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs @@ -36,7 +36,9 @@ Task UpsertFromPairingAsync( /// A cancellation token. /// The distinct server ids whose pool changed. Task> RemoveForOwnerAsync( - ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default); /// Lists the distinct server ids the owner currently holds a credential for in the guild. /// Owning Discord guild snowflake. @@ -44,5 +46,7 @@ Task> RemoveForOwnerAsync( /// A cancellation token. /// The distinct server ids the owner has a credential for. Task> ListServerIdsForOwnerAsync( - ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default); } diff --git a/src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs b/src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs index 923ac14a..fac9493d 100644 --- a/src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs +++ b/src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs @@ -15,7 +15,9 @@ internal sealed class ServerRemovalService( IServerWorkspaceRemover workspace) : IServerRemovalService { /// - public async Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default) + public async Task RemoveServerAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken = default) { await supervisor.StopAsync(guildId, serverId).ConfigureAwait(false); var removed = await servers.RemoveAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); diff --git a/src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs b/src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs index 111547a8..c28f4f82 100644 --- a/src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs +++ b/src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs @@ -22,7 +22,9 @@ internal sealed class AccountDisconnectService( { /// public async Task PreviewAsync( - ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default) + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) { var registration = await registrations.GetAsync(guildId, ownerUserId, cancellationToken).ConfigureAwait(false); var serverIds = await credentials.ListServerIdsForOwnerAsync(guildId, ownerUserId, cancellationToken) @@ -45,7 +47,9 @@ public async Task PreviewAsync( /// public async Task DisconnectAsync( - ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default) + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) { await supervisor.StopListenerAsync(guildId, ownerUserId).ConfigureAwait(false); diff --git a/src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs b/src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs index 408bc644..93492760 100644 --- a/src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs +++ b/src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs @@ -9,7 +9,9 @@ internal interface IAccountDisconnectService /// A cancellation token. /// Whether the user is connected and the names of servers they would be removed from. Task PreviewAsync( - ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default); /// /// Stops the user's FCM listener, marks their registration Disabled, removes all their pool credentials @@ -20,7 +22,9 @@ Task PreviewAsync( /// A cancellation token. /// The number of servers the user was removed from. Task DisconnectAsync( - ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default); } /// What an account disconnect would affect. diff --git a/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs index 48fef7ff..9033c62f 100644 --- a/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs +++ b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs @@ -15,6 +15,9 @@ namespace RustPlusBot.Features.Pairing.Modules; public sealed class CredentialModule(IServiceScopeFactory scopeFactory) : InteractionModuleBase { + private const string DisconnectConfirmId = "pairing:account:disconnect:confirm"; + private const string DisconnectCancelId = "pairing:account:disconnect:cancel"; + /// Opens the credentials modal when the #setup button is clicked. [ComponentInteraction(WorkspaceComponentIds.ConnectAccount)] public async Task OpenAsync() @@ -71,9 +74,6 @@ await FollowupAsync( } } - private const string DisconnectConfirmId = "pairing:account:disconnect:confirm"; - private const string DisconnectCancelId = "pairing:account:disconnect:cancel"; - /// Opens an ephemeral confirmation listing the servers a disconnect would affect. [ComponentInteraction(WorkspaceComponentIds.DisconnectAccount)] public async Task DisconnectPromptAsync() diff --git a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs index 6a4d87d9..a32c5689 100644 --- a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs +++ b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs @@ -64,7 +64,9 @@ public Task CountForServerAsync( /// public async Task> RemoveForOwnerAsync( - ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default) + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) { var owned = await context.PlayerCredentials .Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId) @@ -83,7 +85,9 @@ public async Task> RemoveForOwnerAsync( /// public async Task> ListServerIdsForOwnerAsync( - ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default) => + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) => await context.PlayerCredentials .Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId) .Select(c => c.RustServerId) diff --git a/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs index 04039c0d..5e9de86b 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs @@ -33,9 +33,15 @@ public async Task Disconnect_StopsListener_DisablesRegistration_RemovesCreds_Pub var s1 = Guid.NewGuid(); var s2 = Guid.NewGuid(); regs.GetAsync(10UL, 99UL, Arg.Any()) - .Returns(new FcmRegistration { Id = regId, GuildId = 10UL, OwnerUserId = 99UL }); + .Returns(new FcmRegistration + { + Id = regId, GuildId = 10UL, OwnerUserId = 99UL + }); creds.RemoveForOwnerAsync(10UL, 99UL, Arg.Any()) - .Returns(new List { s1, s2 }); + .Returns(new List + { + s1, s2 + }); var count = await sut.DisconnectAsync(10UL, 99UL); @@ -71,11 +77,24 @@ public async Task Preview_ReportsConnectedAndServerNames() var (sut, _, regs, creds, servers, _) = Create(); var s1 = Guid.NewGuid(); regs.GetAsync(10UL, 99UL, Arg.Any()) - .Returns(new FcmRegistration { Id = Guid.NewGuid(), GuildId = 10UL, OwnerUserId = 99UL }); + .Returns(new FcmRegistration + { + Id = Guid.NewGuid(), GuildId = 10UL, OwnerUserId = 99UL + }); creds.ListServerIdsForOwnerAsync(10UL, 99UL, Arg.Any()) - .Returns(new List { s1 }); + .Returns(new List + { + s1 + }); servers.GetAsync(10UL, s1, Arg.Any()) - .Returns(new RustServer { Id = s1, GuildId = 10UL, Name = "Rustopia", Ip = "1.1.1.1", Port = 28015 }); + .Returns(new RustServer + { + Id = s1, + GuildId = 10UL, + Name = "Rustopia", + Ip = "1.1.1.1", + Port = 28015 + }); var preview = await sut.PreviewAsync(10UL, 99UL); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index 891a72c4..2a856759 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -201,7 +201,14 @@ public async Task ServerInfo_HasRemoveServerButton() var serverId = Guid.NewGuid(); var servers = Substitute.For(); servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.2.3.4", Port = 28015 }); + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "S", + Ip = "1.2.3.4", + Port = 28015 + }); var connections = Substitute.For(); connections.GetStateAsync(1, serverId, Arg.Any()) .Returns(new DomainConnectionState diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs index 908c138e..801343a6 100644 --- a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs @@ -13,7 +13,10 @@ public async Task ConnectionState_RoundTrips_StatusAndPlayerCount() await using var _ = context; await using var __ = connection; - var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; context.RustServers.Add(server); await context.SaveChangesAsync(); var serverId = server.Id; @@ -40,7 +43,10 @@ public async Task ConnectionState_RoundTrips_NullPlayerCount() await using var _ = context; await using var __ = connection; - var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; context.RustServers.Add(server); await context.SaveChangesAsync(); var serverId = server.Id; @@ -64,7 +70,10 @@ public async Task RemovingServer_CascadeDeletesItsConnectionState() await using var _ = context; await using var __ = connection; - var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; context.RustServers.Add(server); context.ConnectionStates.Add(new ConnectionState { diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs index 3a255c82..e1a41316 100644 --- a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs @@ -55,7 +55,10 @@ public async Task UpsertStatus_InsertsThenReportsChangeOnlyWhenDifferent() var (store, context, conn) = Create(); await using var _ = conn; await using var __ = context; - var server = new RustServer { GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 }; + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; context.RustServers.Add(server); await context.SaveChangesAsync(); var serverId = server.Id; diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs index fbb0ca49..0726abe2 100644 --- a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs @@ -126,9 +126,11 @@ public async Task RemoveForOwner_RemovesOwnersCredsAcrossServers_ReturnsDistinct await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 1UL, 1UL, "t1"), markActive: true); await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverB, 1UL, 1UL, "t2"), markActive: true); - await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 2UL, 2UL, "t3"), markActive: false); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 2UL, 2UL, "t3"), + markActive: false); var serverOtherGuild = await SeedServerAsync(context, 20UL); - await store.UpsertFromPairingAsync(new StoreCredentialRequest(20UL, serverOtherGuild, 1UL, 1UL, "t4"), markActive: true); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(20UL, serverOtherGuild, 1UL, 1UL, "t4"), + markActive: true); var affected = await store.RemoveForOwnerAsync(10UL, 1UL); From 62b6f262a60acf85d39203586fb87e9246f20ab4 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 15 Jun 2026 21:57:16 +0200 Subject: [PATCH 14/14] fix(1b-iii): address PR review (resilient consumers, set-based delete, DI forwarding, migration orphan guard) --- .../Hosting/ConnectionHostedService.cs | 42 +++++++++++++--- .../Messages/ServerInfoMessageRenderer.cs | 6 ++- .../WorkspaceServiceCollectionExtensions.cs | 8 +-- .../Credentials/CredentialStore.cs | 14 ++++-- .../20260615183313_ServerRemovalCascade.cs | 5 ++ .../Messages/RendererTests.cs | 50 +++++++++++++++++++ 6 files changed, 107 insertions(+), 18 deletions(-) diff --git a/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs b/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs index bd0dd949..e4f2c3b9 100644 --- a/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs +++ b/src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs @@ -76,22 +76,48 @@ await Task.WhenAll( private async Task ConsumeRegisteredAsync(CancellationToken cancellationToken) { - await foreach (var registered in eventBus.SubscribeAsync(cancellationToken) - .ConfigureAwait(false)) + try { - await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId, cancellationToken) - .ConfigureAwait(false); + await foreach (var registered in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId, cancellationToken) + .ConfigureAwait(false); + } } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch is intentional: one faulting consumer must not stop the other or crash the host. + catch (Exception ex) + { + LogLoopFaulted(logger, ex); + } +#pragma warning restore CA1031 } private async Task ConsumeCredentialsChangedAsync(CancellationToken cancellationToken) { - await foreach (var changed in eventBus.SubscribeAsync(cancellationToken) - .ConfigureAwait(false)) + try { - await supervisor.EnsureConnectionAsync(changed.GuildId, changed.ServerId, cancellationToken) - .ConfigureAwait(false); + await foreach (var changed in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await supervisor.EnsureConnectionAsync(changed.GuildId, changed.ServerId, cancellationToken) + .ConfigureAwait(false); + } } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch is intentional: one faulting consumer must not stop the other or crash the host. + catch (Exception ex) + { + LogLoopFaulted(logger, ex); + } +#pragma warning restore CA1031 } [LoggerMessage(Level = LogLevel.Error, Message = "Connection hosted-service loop faulted.")] diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs index 2bdda2ef..c49185ad 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs @@ -77,13 +77,15 @@ public async ValueTask RenderAsync(MessageRenderContext context, select.AddOption(label, credential.Id.ToString(), isDefault: credential.Id == active?.Id); } - builder.WithSelectMenu(select); + builder.WithSelectMenu(select, row: 0); } + // Keep the remove button on its own row: Discord rejects an action row that mixes a select with buttons. builder.WithButton( localizer.Get("server.info.remove.button", context.Culture), $"{WorkspaceComponentIds.ServerInfoRemovePrefix}{serverId}", - ButtonStyle.Danger); + ButtonStyle.Danger, + row: eligible.Count > 0 ? 1 : 0); return new MessagePayload(null, embed.Build(), builder.Build()); } diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index fb61bf6e..40034e6b 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -41,10 +41,12 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) services.AddScoped(); services.AddScoped(); - // Reconciler + teardown (scoped). + // Reconciler + teardown (scoped). Register the teardown service once and expose both interfaces + // off the same scoped instance, so resolving either does not create a second instance. services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); // Options (Host binds the "Workspace" section; default = danger commands off). services.AddOptions(); diff --git a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs index a32c5689..f1cb19ba 100644 --- a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs +++ b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs @@ -68,18 +68,22 @@ public async Task> RemoveForOwnerAsync( ulong ownerUserId, CancellationToken cancellationToken = default) { - var owned = await context.PlayerCredentials + // Project the affected server ids first (no token material loaded), then delete set-based. + var serverIds = await context.PlayerCredentials .Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId) + .Select(c => c.RustServerId) + .Distinct() .ToListAsync(cancellationToken) .ConfigureAwait(false); - if (owned.Count == 0) + if (serverIds.Count == 0) { return []; } - var serverIds = owned.Select(c => c.RustServerId).Distinct().ToList(); - context.PlayerCredentials.RemoveRange(owned); - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await context.PlayerCredentials + .Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); return serverIds; } diff --git a/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs b/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs index 3479d6f7..da08ee9f 100644 --- a/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs +++ b/src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs @@ -10,6 +10,11 @@ public partial class ServerRemovalCascade : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { + // Defensive: remove any ConnectionStates orphaned before this FK existed so adding the + // constraint cannot fail. No server-removal path existed before 1b-iii, so this is normally a no-op. + migrationBuilder.Sql( + "DELETE FROM \"ConnectionStates\" WHERE \"RustServerId\" NOT IN (SELECT \"Id\" FROM \"RustServers\");"); + migrationBuilder.AddForeignKey( name: "FK_ConnectionStates_RustServers_RustServerId", table: "ConnectionStates", diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index 2a856759..dae247cb 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -225,4 +225,54 @@ public async Task ServerInfo_HasRemoveServerButton() .SelectMany(r => r.Components).OfType(); Assert.Contains(buttons, b => b.CustomId == $"workspace:info:remove:{serverId}"); } + + [Fact] + public async Task ServerInfo_SwapSelectAndRemoveButton_AreInSeparateActionRows() + { + var serverId = Guid.NewGuid(); + var credId = Guid.NewGuid(); + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "S", + Ip = "1.2.3.4", + Port = 28015 + }); + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, + GuildId = 1, + ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, + PlayerCount = 1, + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns(new List + { + new() + { + Id = credId, + GuildId = 1, + RustServerId = serverId, + OwnerUserId = 7, + SteamId = 5UL, + Status = CredentialStatus.Active + }, + }); + var renderer = new ServerInfoMessageRenderer(servers, connections, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var rows = payload.Components!.Components.OfType().ToList(); + // Discord rejects an action row that mixes a select menu with buttons. + Assert.DoesNotContain(rows, r => + r.Components.OfType().Any() && r.Components.OfType().Any()); + Assert.Contains(rows, r => r.Components.OfType().Any()); + Assert.Contains(rows, r => r.Components.OfType().Any()); + } }