Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,28 @@ Task<Guid> UpsertFromPairingAsync(
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The number of stored credentials for that (guild, server).</returns>
Task<int> CountForServerAsync(ulong guildId, Guid rustServerId, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
/// <param name="guildId">Owning Discord guild snowflake.</param>
/// <param name="ownerUserId">The Discord user whose credentials are removed.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The distinct server ids whose pool changed.</returns>
Task<IReadOnlyList<Guid>> RemoveForOwnerAsync(
ulong guildId,
ulong ownerUserId,
CancellationToken cancellationToken = default);

/// <summary>Lists the distinct server ids the owner currently holds a credential for in the guild.</summary>
/// <param name="guildId">Owning Discord guild snowflake.</param>
/// <param name="ownerUserId">The Discord user.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The distinct server ids the owner has a credential for.</returns>
Task<IReadOnlyList<Guid>> ListServerIdsForOwnerAsync(
ulong guildId,
ulong ownerUserId,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace RustPlusBot.Abstractions.Events;

/// <summary>
/// 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.
/// </summary>
/// <param name="GuildId">The owning Discord guild snowflake.</param>
/// <param name="ServerId">The affected server.</param>
public sealed record ServerCredentialsChangedEvent(ulong GuildId, Guid ServerId);
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +19,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services

services.AddSingleton<IRustSocketSource, RustPlusSocketSource>();
services.AddSingleton<IConnectionSupervisor, ConnectionSupervisor>();
services.AddScoped<IServerRemovalService, ServerRemovalService>();

// Contribute this assembly's interaction modules to the Discord layer.
services.AddSingleton(new InteractionModuleAssembly(typeof(ConnectionServiceCollectionExtensions).Assembly));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,34 @@ 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 Task.WhenAll(
ConsumeRegisteredAsync(cancellationToken),
ConsumeCredentialsChangedAsync(cancellationToken))
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Shutting down.
}
#pragma warning disable CA1031 // Broad catch is intentional: a faulting consumer must not crash the host.
catch (Exception ex)
{
LogLoopFaulted(logger, ex);
}
#pragma warning restore CA1031
}

private async Task ConsumeRegisteredAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var registered in eventBus.SubscribeAsync<ServerRegisteredEvent>(cancellationToken)
.ConfigureAwait(false))
{
Expand All @@ -68,7 +89,30 @@ await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId,
{
// Shutting down.
}
#pragma warning disable CA1031 // Broad catch is intentional: a faulting consumer must not crash the host.
#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)
{
try
{
await foreach (var changed in eventBus.SubscribeAsync<ServerCredentialsChangedEvent>(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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Handles the #info "Remove server" button and its confirmation (ManageGuild).</summary>
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
public sealed class ServerRemovalModule(IServiceScopeFactory scopeFactory)
: InteractionModuleBase<SocketInteractionContext>
{
private const string RemoveConfirmPrefix = "connections:server:remove:confirm:";
private const string RemoveCancelId = "connections:server:remove:cancel";

/// <summary>Opens an ephemeral confirmation for removing the server.</summary>
/// <param name="serverIdRaw">The server id captured from the custom id.</param>
[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);
}

/// <summary>Removes the server after confirmation.</summary>
/// <param name="serverIdRaw">The server id captured from the custom id.</param>
[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<IServerRemovalService>();
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);
}
}

/// <summary>Cancels the removal.</summary>
[ComponentInteraction(RemoveCancelId)]
public async Task RemoveCancelAsync() =>
await RespondAsync("Cancelled.", ephemeral: true).ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace RustPlusBot.Features.Connections.Removal;

/// <summary>Orchestrates removing a server: stop the socket, delete the row (cascades), tear down the workspace.</summary>
internal interface IServerRemovalService
{
/// <summary>Removes a server end-to-end. Returns true if a server row was deleted.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The server to remove.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>True if a matching server row was removed.</returns>
Task<bool> RemoveServerAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using RustPlusBot.Features.Connections.Supervisor;
using RustPlusBot.Features.Workspace.Teardown;
using RustPlusBot.Persistence.Servers;

namespace RustPlusBot.Features.Connections.Removal;

/// <summary>Default <see cref="IServerRemovalService"/>. 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).</summary>
/// <param name="supervisor">Stops the live socket.</param>
/// <param name="servers">Deletes the RustServer (cascades credentials + connection state).</param>
/// <param name="workspace">Tears down the server's Discord category/channels/records.</param>
internal sealed class ServerRemovalService(
IConnectionSupervisor supervisor,
IServerService servers,
IServerWorkspaceRemover workspace) : IServerRemovalService
{
/// <inheritdoc />
public async Task<bool> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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;

/// <summary>Default <see cref="IAccountDisconnectService"/>.</summary>
/// <param name="supervisor">Stops the user's FCM listener.</param>
/// <param name="registrations">The FCM registration store.</param>
/// <param name="credentials">The player-credential pool store.</param>
/// <param name="servers">Resolves server names for the preview.</param>
/// <param name="eventBus">Publishes ServerCredentialsChangedEvent per affected server.</param>
internal sealed class AccountDisconnectService(
IPairingSupervisor supervisor,
IFcmRegistrationStore registrations,
ICredentialStore credentials,
IServerService servers,
IEventBus eventBus) : IAccountDisconnectService
{
/// <inheritdoc />
public async Task<AccountDisconnectPreview> 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<string>(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);
}

/// <inheritdoc />
public async Task<int> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace RustPlusBot.Features.Pairing.Accounts;

/// <summary>Full account disconnect: stop the listener, disable the registration, drop the user's pool credentials.</summary>
internal interface IAccountDisconnectService
{
/// <summary>Reads what a disconnect would affect, for the confirmation dialog.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="ownerUserId">The Discord user.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>Whether the user is connected and the names of servers they would be removed from.</returns>
Task<AccountDisconnectPreview> PreviewAsync(
ulong guildId,
ulong ownerUserId,
CancellationToken cancellationToken = default);

/// <summary>
/// Stops the user's FCM listener, marks their registration Disabled, removes all their pool credentials
/// in the guild, and publishes a <c>ServerCredentialsChangedEvent</c> per affected server.
/// </summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="ownerUserId">The Discord user.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The number of servers the user was removed from.</returns>
Task<int> DisconnectAsync(
ulong guildId,
ulong ownerUserId,
CancellationToken cancellationToken = default);
}

/// <summary>What an account disconnect would affect.</summary>
/// <param name="IsConnected">True if the user has a non-Disabled registration or any pool credential.</param>
/// <param name="AffectedServerNames">Names of servers the user holds a credential for.</param>
internal sealed record AccountDisconnectPreview(bool IsConnected, IReadOnlyList<string> AffectedServerNames);
Loading
Loading