Skip to content

Commit 059abec

Browse files
HandyS11claude
andauthored
Subsystem 1b-iii: server removal & account disconnect (cascade, failover, UI) (#7)
* 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 <noreply@anthropic.com> * feat(persistence): remove/list a user's credentials across a guild Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(abstractions): add ServerCredentialsChangedEvent * feat(pairing): expose StopListenerAsync on the supervisor Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(pairing): account disconnect service (disable + drop creds + notify) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(workspace): public IServerWorkspaceRemover seam * feat(connections): server removal orchestrator Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): failover on ServerCredentialsChangedEvent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): refresh #info on ServerCredentialsChangedEvent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): render disconnect + remove-server buttons (EN/FR) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(pairing): #setup disconnect-account button with confirm * feat(connections): #info remove-server button with confirm * style: jb reformat for 1b-iii * fix(1b-iii): address PR review (resilient consumers, set-based delete, DI forwarding, migration orphan guard) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 81275ac commit 059abec

41 files changed

Lines changed: 1493 additions & 17 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,28 @@ Task<Guid> UpsertFromPairingAsync(
2525
/// <param name="cancellationToken">A cancellation token.</param>
2626
/// <returns>The number of stored credentials for that (guild, server).</returns>
2727
Task<int> CountForServerAsync(ulong guildId, Guid rustServerId, CancellationToken cancellationToken = default);
28+
29+
/// <summary>
30+
/// Removes every player-credential the owner holds across all servers in the guild (full account
31+
/// removal). Returns the distinct server ids whose pool was affected, so callers can re-evaluate
32+
/// those connections.
33+
/// </summary>
34+
/// <param name="guildId">Owning Discord guild snowflake.</param>
35+
/// <param name="ownerUserId">The Discord user whose credentials are removed.</param>
36+
/// <param name="cancellationToken">A cancellation token.</param>
37+
/// <returns>The distinct server ids whose pool changed.</returns>
38+
Task<IReadOnlyList<Guid>> RemoveForOwnerAsync(
39+
ulong guildId,
40+
ulong ownerUserId,
41+
CancellationToken cancellationToken = default);
42+
43+
/// <summary>Lists the distinct server ids the owner currently holds a credential for in the guild.</summary>
44+
/// <param name="guildId">Owning Discord guild snowflake.</param>
45+
/// <param name="ownerUserId">The Discord user.</param>
46+
/// <param name="cancellationToken">A cancellation token.</param>
47+
/// <returns>The distinct server ids the owner has a credential for.</returns>
48+
Task<IReadOnlyList<Guid>> ListServerIdsForOwnerAsync(
49+
ulong guildId,
50+
ulong ownerUserId,
51+
CancellationToken cancellationToken = default);
2852
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>
4+
/// Raised when a server's credential pool changed out-of-band (e.g. a user disconnected their account),
5+
/// so the live connection should be re-evaluated and the #info view refreshed.
6+
/// </summary>
7+
/// <param name="GuildId">The owning Discord guild snowflake.</param>
8+
/// <param name="ServerId">The affected server.</param>
9+
public sealed record ServerCredentialsChangedEvent(ulong GuildId, Guid ServerId);

src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using RustPlusBot.Discord;
33
using RustPlusBot.Features.Connections.Hosting;
44
using RustPlusBot.Features.Connections.Listening;
5+
using RustPlusBot.Features.Connections.Removal;
56
using RustPlusBot.Features.Connections.Supervisor;
67

78
namespace RustPlusBot.Features.Connections;
@@ -18,6 +19,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services
1819

1920
services.AddSingleton<IRustSocketSource, RustPlusSocketSource>();
2021
services.AddSingleton<IConnectionSupervisor, ConnectionSupervisor>();
22+
services.AddScoped<IServerRemovalService, ServerRemovalService>();
2123

2224
// Contribute this assembly's interaction modules to the Discord layer.
2325
services.AddSingleton(new InteractionModuleAssembly(typeof(ConnectionServiceCollectionExtensions).Assembly));

src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,34 @@ public async Task StopAsync(CancellationToken cancellationToken)
5050

5151
private async Task RunAsync(CancellationToken cancellationToken)
5252
{
53-
// Subscription is registered when this loop first awaits the bus, after StartAllAsync completes.
53+
// Subscriptions register when each loop first awaits the bus, after StartAllAsync completes.
5454
// The in-process bus does not replay, so events published before this point are not delivered. Safe:
55-
// the server-registered event is only raised by the FCM pairing flow at runtime, long after startup.
55+
// both events are only raised at runtime, long after startup.
5656
try
5757
{
5858
await supervisor.StartAllAsync(cancellationToken).ConfigureAwait(false);
5959

60+
await Task.WhenAll(
61+
ConsumeRegisteredAsync(cancellationToken),
62+
ConsumeCredentialsChangedAsync(cancellationToken))
63+
.ConfigureAwait(false);
64+
}
65+
catch (OperationCanceledException)
66+
{
67+
// Shutting down.
68+
}
69+
#pragma warning disable CA1031 // Broad catch is intentional: a faulting consumer must not crash the host.
70+
catch (Exception ex)
71+
{
72+
LogLoopFaulted(logger, ex);
73+
}
74+
#pragma warning restore CA1031
75+
}
76+
77+
private async Task ConsumeRegisteredAsync(CancellationToken cancellationToken)
78+
{
79+
try
80+
{
6081
await foreach (var registered in eventBus.SubscribeAsync<ServerRegisteredEvent>(cancellationToken)
6182
.ConfigureAwait(false))
6283
{
@@ -68,7 +89,30 @@ await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId,
6889
{
6990
// Shutting down.
7091
}
71-
#pragma warning disable CA1031 // Broad catch is intentional: a faulting consumer must not crash the host.
92+
#pragma warning disable CA1031 // Broad catch is intentional: one faulting consumer must not stop the other or crash the host.
93+
catch (Exception ex)
94+
{
95+
LogLoopFaulted(logger, ex);
96+
}
97+
#pragma warning restore CA1031
98+
}
99+
100+
private async Task ConsumeCredentialsChangedAsync(CancellationToken cancellationToken)
101+
{
102+
try
103+
{
104+
await foreach (var changed in eventBus.SubscribeAsync<ServerCredentialsChangedEvent>(cancellationToken)
105+
.ConfigureAwait(false))
106+
{
107+
await supervisor.EnsureConnectionAsync(changed.GuildId, changed.ServerId, cancellationToken)
108+
.ConfigureAwait(false);
109+
}
110+
}
111+
catch (OperationCanceledException)
112+
{
113+
// Shutting down.
114+
}
115+
#pragma warning disable CA1031 // Broad catch is intentional: one faulting consumer must not stop the other or crash the host.
72116
catch (Exception ex)
73117
{
74118
LogLoopFaulted(logger, ex);
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using Discord;
2+
using Discord.Interactions;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using RustPlusBot.Features.Connections.Removal;
5+
using RustPlusBot.Features.Workspace;
6+
7+
namespace RustPlusBot.Features.Connections.Modules;
8+
9+
/// <summary>Handles the #info "Remove server" button and its confirmation (ManageGuild).</summary>
10+
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
11+
public sealed class ServerRemovalModule(IServiceScopeFactory scopeFactory)
12+
: InteractionModuleBase<SocketInteractionContext>
13+
{
14+
private const string RemoveConfirmPrefix = "connections:server:remove:confirm:";
15+
private const string RemoveCancelId = "connections:server:remove:cancel";
16+
17+
/// <summary>Opens an ephemeral confirmation for removing the server.</summary>
18+
/// <param name="serverIdRaw">The server id captured from the custom id.</param>
19+
[ComponentInteraction(WorkspaceComponentIds.ServerInfoRemovePrefix + "*")]
20+
[RequireUserPermission(GuildPermission.ManageGuild)]
21+
public async Task RemovePromptAsync(string serverIdRaw)
22+
{
23+
if (Context.Guild is null)
24+
{
25+
await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false);
26+
return;
27+
}
28+
29+
if (!Guid.TryParse(serverIdRaw, out var serverId))
30+
{
31+
await RespondAsync("That selection wasn't valid.", ephemeral: true).ConfigureAwait(false);
32+
return;
33+
}
34+
35+
var components = new ComponentBuilder()
36+
.WithButton("Remove server", $"{RemoveConfirmPrefix}{serverId}", ButtonStyle.Danger)
37+
.WithButton("Cancel", RemoveCancelId, ButtonStyle.Secondary)
38+
.Build();
39+
await RespondAsync(
40+
"This permanently deletes the category, all channels, and stored credentials for this server. Continue?",
41+
ephemeral: true, components: components)
42+
.ConfigureAwait(false);
43+
}
44+
45+
/// <summary>Removes the server after confirmation.</summary>
46+
/// <param name="serverIdRaw">The server id captured from the custom id.</param>
47+
[ComponentInteraction(RemoveConfirmPrefix + "*")]
48+
[RequireUserPermission(GuildPermission.ManageGuild)]
49+
public async Task RemoveConfirmAsync(string serverIdRaw)
50+
{
51+
if (Context.Guild is null)
52+
{
53+
await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false);
54+
return;
55+
}
56+
57+
if (!Guid.TryParse(serverIdRaw, out var serverId))
58+
{
59+
await RespondAsync("That selection wasn't valid.", ephemeral: true).ConfigureAwait(false);
60+
return;
61+
}
62+
63+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
64+
65+
var scope = scopeFactory.CreateAsyncScope();
66+
await using (scope.ConfigureAwait(false))
67+
{
68+
var removal = scope.ServiceProvider.GetRequiredService<IServerRemovalService>();
69+
var removed = await removal.RemoveServerAsync(Context.Guild.Id, serverId).ConfigureAwait(false);
70+
await FollowupAsync(removed ? "Server removed." : "That server no longer exists.", ephemeral: true)
71+
.ConfigureAwait(false);
72+
}
73+
}
74+
75+
/// <summary>Cancels the removal.</summary>
76+
[ComponentInteraction(RemoveCancelId)]
77+
public async Task RemoveCancelAsync() =>
78+
await RespondAsync("Cancelled.", ephemeral: true).ConfigureAwait(false);
79+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace RustPlusBot.Features.Connections.Removal;
2+
3+
/// <summary>Orchestrates removing a server: stop the socket, delete the row (cascades), tear down the workspace.</summary>
4+
internal interface IServerRemovalService
5+
{
6+
/// <summary>Removes a server end-to-end. Returns true if a server row was deleted.</summary>
7+
/// <param name="guildId">The owning guild snowflake.</param>
8+
/// <param name="serverId">The server to remove.</param>
9+
/// <param name="cancellationToken">A cancellation token.</param>
10+
/// <returns>True if a matching server row was removed.</returns>
11+
Task<bool> RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
12+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using RustPlusBot.Features.Connections.Supervisor;
2+
using RustPlusBot.Features.Workspace.Teardown;
3+
using RustPlusBot.Persistence.Servers;
4+
5+
namespace RustPlusBot.Features.Connections.Removal;
6+
7+
/// <summary>Default <see cref="IServerRemovalService"/>. Order matters: stop the socket before deleting the row,
8+
/// so no late status write re-inserts a connection-state row against a deleted server (FK violation).</summary>
9+
/// <param name="supervisor">Stops the live socket.</param>
10+
/// <param name="servers">Deletes the RustServer (cascades credentials + connection state).</param>
11+
/// <param name="workspace">Tears down the server's Discord category/channels/records.</param>
12+
internal sealed class ServerRemovalService(
13+
IConnectionSupervisor supervisor,
14+
IServerService servers,
15+
IServerWorkspaceRemover workspace) : IServerRemovalService
16+
{
17+
/// <inheritdoc />
18+
public async Task<bool> RemoveServerAsync(ulong guildId,
19+
Guid serverId,
20+
CancellationToken cancellationToken = default)
21+
{
22+
await supervisor.StopAsync(guildId, serverId).ConfigureAwait(false);
23+
var removed = await servers.RemoveAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
24+
await workspace.RemoveServerAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
25+
return removed;
26+
}
27+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using RustPlusBot.Abstractions.Credentials;
2+
using RustPlusBot.Abstractions.Events;
3+
using RustPlusBot.Domain.Credentials;
4+
using RustPlusBot.Features.Pairing.Supervisor;
5+
using RustPlusBot.Persistence.Credentials;
6+
using RustPlusBot.Persistence.Servers;
7+
8+
namespace RustPlusBot.Features.Pairing.Accounts;
9+
10+
/// <summary>Default <see cref="IAccountDisconnectService"/>.</summary>
11+
/// <param name="supervisor">Stops the user's FCM listener.</param>
12+
/// <param name="registrations">The FCM registration store.</param>
13+
/// <param name="credentials">The player-credential pool store.</param>
14+
/// <param name="servers">Resolves server names for the preview.</param>
15+
/// <param name="eventBus">Publishes ServerCredentialsChangedEvent per affected server.</param>
16+
internal sealed class AccountDisconnectService(
17+
IPairingSupervisor supervisor,
18+
IFcmRegistrationStore registrations,
19+
ICredentialStore credentials,
20+
IServerService servers,
21+
IEventBus eventBus) : IAccountDisconnectService
22+
{
23+
/// <inheritdoc />
24+
public async Task<AccountDisconnectPreview> PreviewAsync(
25+
ulong guildId,
26+
ulong ownerUserId,
27+
CancellationToken cancellationToken = default)
28+
{
29+
var registration = await registrations.GetAsync(guildId, ownerUserId, cancellationToken).ConfigureAwait(false);
30+
var serverIds = await credentials.ListServerIdsForOwnerAsync(guildId, ownerUserId, cancellationToken)
31+
.ConfigureAwait(false);
32+
33+
var names = new List<string>(serverIds.Count);
34+
foreach (var serverId in serverIds)
35+
{
36+
var server = await servers.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
37+
if (server is not null)
38+
{
39+
names.Add(server.Name);
40+
}
41+
}
42+
43+
var isConnected = (registration is not null && registration.Status != FcmRegistrationStatus.Disabled)
44+
|| serverIds.Count > 0;
45+
return new AccountDisconnectPreview(isConnected, names);
46+
}
47+
48+
/// <inheritdoc />
49+
public async Task<int> DisconnectAsync(
50+
ulong guildId,
51+
ulong ownerUserId,
52+
CancellationToken cancellationToken = default)
53+
{
54+
await supervisor.StopListenerAsync(guildId, ownerUserId).ConfigureAwait(false);
55+
56+
var registration = await registrations.GetAsync(guildId, ownerUserId, cancellationToken).ConfigureAwait(false);
57+
if (registration is not null)
58+
{
59+
await registrations.SetStatusAsync(registration.Id, FcmRegistrationStatus.Disabled, cancellationToken)
60+
.ConfigureAwait(false);
61+
}
62+
63+
var affected = await credentials.RemoveForOwnerAsync(guildId, ownerUserId, cancellationToken)
64+
.ConfigureAwait(false);
65+
foreach (var serverId in affected)
66+
{
67+
await eventBus.PublishAsync(new ServerCredentialsChangedEvent(guildId, serverId), cancellationToken)
68+
.ConfigureAwait(false);
69+
}
70+
71+
return affected.Count;
72+
}
73+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace RustPlusBot.Features.Pairing.Accounts;
2+
3+
/// <summary>Full account disconnect: stop the listener, disable the registration, drop the user's pool credentials.</summary>
4+
internal interface IAccountDisconnectService
5+
{
6+
/// <summary>Reads what a disconnect would affect, for the confirmation dialog.</summary>
7+
/// <param name="guildId">The owning guild snowflake.</param>
8+
/// <param name="ownerUserId">The Discord user.</param>
9+
/// <param name="cancellationToken">A cancellation token.</param>
10+
/// <returns>Whether the user is connected and the names of servers they would be removed from.</returns>
11+
Task<AccountDisconnectPreview> PreviewAsync(
12+
ulong guildId,
13+
ulong ownerUserId,
14+
CancellationToken cancellationToken = default);
15+
16+
/// <summary>
17+
/// Stops the user's FCM listener, marks their registration Disabled, removes all their pool credentials
18+
/// in the guild, and publishes a <c>ServerCredentialsChangedEvent</c> per affected server.
19+
/// </summary>
20+
/// <param name="guildId">The owning guild snowflake.</param>
21+
/// <param name="ownerUserId">The Discord user.</param>
22+
/// <param name="cancellationToken">A cancellation token.</param>
23+
/// <returns>The number of servers the user was removed from.</returns>
24+
Task<int> DisconnectAsync(
25+
ulong guildId,
26+
ulong ownerUserId,
27+
CancellationToken cancellationToken = default);
28+
}
29+
30+
/// <summary>What an account disconnect would affect.</summary>
31+
/// <param name="IsConnected">True if the user has a non-Disabled registration or any pool credential.</param>
32+
/// <param name="AffectedServerNames">Names of servers the user holds a credential for.</param>
33+
internal sealed record AccountDisconnectPreview(bool IsConnected, IReadOnlyList<string> AffectedServerNames);

0 commit comments

Comments
 (0)