Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)

services.AddSingleton<CommandCooldown>();
services.AddSingleton<BotUptime>();
services.AddOptions<MaintenanceOptions>();
services.AddRustPlusBotLocalization();
services.AddItemData();

Expand Down
11 changes: 11 additions & 0 deletions src/RustPlusBot.Features.Commands/MaintenanceOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace RustPlusBot.Features.Commands;

/// <summary>
/// Gates the dangerous maintenance command (/admin reset-database). Bound to the same "Workspace"
/// config section as WorkspaceOptions, so a single EnableDangerCommands flag governs all danger commands.
/// </summary>
public sealed class MaintenanceOptions
{
/// <summary>Enables the dangerous /admin reset-database command.</summary>
public bool EnableDangerCommands { get; set; }
}
76 changes: 76 additions & 0 deletions src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Diagnostics;
using System.Globalization;
using Discord;
using Discord.Interactions;
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.Commands.Hosting;
using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Servers;

namespace RustPlusBot.Features.Commands.Modules;

/// <summary>Read-only diagnostics: /ping and /status.</summary>
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
public sealed class DiagnosticsModule(IServiceScopeFactory scopeFactory)
: InteractionModuleBase<SocketInteractionContext>
{
/// <summary>Reports Discord gateway latency and the REST ack round-trip.</summary>
[SlashCommand("ping", "Show the bot's Discord latency")]
public async Task PingAsync()
{
var stopwatch = Stopwatch.StartNew();
await DeferAsync(ephemeral: true).ConfigureAwait(false);
stopwatch.Stop();

var text = string.Create(CultureInfo.InvariantCulture,
$"Pong! Gateway: {Context.Client.Latency} ms · Response: {stopwatch.ElapsedMilliseconds} ms");
await FollowupAsync(text, ephemeral: true).ConfigureAwait(false);
}

/// <summary>Shows uptime, latency, per-server connection status, and counts.</summary>
[SlashCommand("status", "Show bot health and connection status")]
public async Task StatusAsync()
{
if (Context.Guild is null)
{
await RespondAsync("This command 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 uptime = scope.ServiceProvider.GetRequiredService<BotUptime>();
var servers = scope.ServiceProvider.GetRequiredService<IServerService>();
var connections = scope.ServiceProvider.GetRequiredService<IConnectionStore>();

var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false);
var states = (await connections.GetStatesForGuildAsync(Context.Guild.Id).ConfigureAwait(false))
.ToDictionary(state => state.RustServerId);

var embed = new EmbedBuilder()
.WithTitle("Bot status")
.AddField("Uptime", DurationFormat.Compact(uptime.Elapsed), inline: true)
.AddField("Gateway latency",
string.Create(CultureInfo.InvariantCulture, $"{Context.Client.Latency} ms"), inline: true)
.AddField("Guilds",
Context.Client.Guilds.Count.ToString(CultureInfo.InvariantCulture), inline: true)
.AddField("Servers (this guild)",
known.Count.ToString(CultureInfo.InvariantCulture), inline: true);

// Cap server fields so the embed stays under Discord's 25-field limit (4 header fields above).
foreach (var server in known.Take(20))
{
var line = states.TryGetValue(server.Id, out var state)
? string.Create(CultureInfo.InvariantCulture,
$"{state.Status} · {state.PlayerCount?.ToString(CultureInfo.InvariantCulture) ?? "?"} players")
: "unknown";
embed.AddField(server.Name, line);
}

await FollowupAsync(ephemeral: true, embed: embed.Build()).ConfigureAwait(false);
}
}
}
60 changes: 60 additions & 0 deletions src/RustPlusBot.Features.Commands/Modules/MaintenanceModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Discord;
using Discord.Interactions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using RustPlusBot.Persistence.Maintenance;

namespace RustPlusBot.Features.Commands.Modules;

/// <summary>Dangerous, danger-gated bot maintenance commands.</summary>
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
/// <param name="options">Gates the command behind the danger flag.</param>
[Group("admin", "Bot maintenance (dangerous)")]
[RequireUserPermission(GuildPermission.ManageGuild)]
public sealed class MaintenanceModule(
IServiceScopeFactory scopeFactory,
IOptions<MaintenanceOptions> options) : InteractionModuleBase<SocketInteractionContext>
{
/// <summary>Wipes all bot data across all guilds after a typed confirmation.</summary>
/// <param name="confirm">Must equal the literal "RESET" to proceed.</param>
[SlashCommand("reset-database", "Wipe ALL bot data across ALL servers (dangerous)")]
public async Task ResetDatabaseAsync(
[Summary("confirm", "Type RESET to confirm")]
string confirm)
{
if (Context.Guild is null)
{
await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false);
return;
}

if (!options.Value.EnableDangerCommands)
{
await RespondAsync(
"Dangerous maintenance commands are disabled. Set `Workspace:EnableDangerCommands` to enable them.",
ephemeral: true).ConfigureAwait(false);
return;
}

if (!string.Equals(confirm, "RESET", StringComparison.Ordinal))
{
await RespondAsync("Type `RESET` exactly to confirm the database wipe.", ephemeral: true)
.ConfigureAwait(false);
return;
}

await DeferAsync(ephemeral: true).ConfigureAwait(false);
var scope = scopeFactory.CreateAsyncScope();
try
{
var maintenance = scope.ServiceProvider.GetRequiredService<IDatabaseMaintenanceService>();
await maintenance.ClearAllAsync().ConfigureAwait(false);
await FollowupAsync("Database cleared. **Restart the bot** for a clean state.", ephemeral: true)
.ConfigureAwait(false);
}
finally
{
await scope.DisposeAsync().ConfigureAwait(false);
}
}
}
128 changes: 128 additions & 0 deletions src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ public sealed class WorkspaceAdminModule(
/// <summary>Custom id for the reset confirmation button.</summary>
public const string ConfirmResetId = "workspace:reset:confirm";

/// <summary>Custom id for the rebuild confirmation button.</summary>
public const string ConfirmRebuildId = "workspace:rebuild:confirm";

/// <summary>Custom id for the purge confirmation button.</summary>
public const string ConfirmPurgeId = "workspace:purge:confirm";

/// <summary>Prompts to delete the entire provisioned workspace (dev-gated).</summary>
[SlashCommand("reset", "Delete ALL of the bot's channels and records in this Discord server (dangerous)")]
public async Task ResetAsync()
Expand Down Expand Up @@ -98,6 +104,128 @@ await FollowupAsync($"Registered **{name}** and published ServerRegisteredEvent.
}
}

/// <summary>Recreates any missing categories/channels/messages without deleting data.</summary>
[SlashCommand("repair", "Recreate any missing bot channels without deleting data")]
public async Task RepairAsync()
{
if (Context.Guild is null)
{
await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false);
return;
}

await DeferAsync(ephemeral: true).ConfigureAwait(false);
var scope = scopeFactory.CreateAsyncScope();
try
{
var reconciler = scope.ServiceProvider.GetRequiredService<IWorkspaceReconciler>();
await reconciler.HealGuildAsync(Context.Guild.Id).ConfigureAwait(false);
await FollowupAsync("Workspace repaired.", ephemeral: true).ConfigureAwait(false);
}
finally
{
await scope.DisposeAsync().ConfigureAwait(false);
}
}

/// <summary>Prompts to delete and re-provision the entire workspace (dev-gated).</summary>
[SlashCommand("rebuild", "Delete and re-create all the bot's channels here (dangerous)")]
public async Task RebuildAsync()
{
if (!await EnsureEnabledAsync().ConfigureAwait(false))
{
return;
}

var components = new ComponentBuilder()
.WithButton("Confirm rebuild", ConfirmRebuildId, ButtonStyle.Danger)
.Build();
await RespondAsync(
"This deletes every provisioned channel and re-creates them from scratch. Confirm?",
ephemeral: true, components: components).ConfigureAwait(false);
}

/// <summary>Executes the rebuild after confirmation.</summary>
[ComponentInteraction(ConfirmRebuildId)]
public async Task ConfirmRebuildAsync()
{
if (!await EnsureEnabledAsync().ConfigureAwait(false))
{
return;
}

await DeferAsync(ephemeral: true).ConfigureAwait(false);
var scope = scopeFactory.CreateAsyncScope();
try
{
var teardown = scope.ServiceProvider.GetRequiredService<IWorkspaceTeardownService>();
var reconciler = scope.ServiceProvider.GetRequiredService<IWorkspaceReconciler>();
var servers = scope.ServiceProvider.GetRequiredService<IServerService>();

await teardown.ResetGuildAsync(Context.Guild.Id).ConfigureAwait(false);

var result = await reconciler.ReconcileGlobalAsync(Context.Guild.Id).ConfigureAwait(false);
if (result.Status == ReconcileStatus.MissingPermissions)
{
await FollowupAsync(
$"I'm missing required permissions: {string.Join(", ", result.MissingPermissions)}.",
ephemeral: true).ConfigureAwait(false);
return;
}

foreach (var server in await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false))
{
await reconciler.ReconcileServerAsync(Context.Guild.Id, server.Id).ConfigureAwait(false);
}

await FollowupAsync("Workspace rebuilt.", ephemeral: true).ConfigureAwait(false);
}
finally
{
await scope.DisposeAsync().ConfigureAwait(false);
}
}

/// <summary>Prompts to delete all of this guild's data (dev-gated).</summary>
[SlashCommand("purge", "Delete ALL of this server's bot data (servers, settings, channels) (dangerous)")]
public async Task PurgeAsync()
{
if (!await EnsureEnabledAsync().ConfigureAwait(false))
{
return;
}

var components = new ComponentBuilder()
.WithButton("Confirm purge", ConfirmPurgeId, ButtonStyle.Danger)
.Build();
await RespondAsync(
"This deletes every server, setting, and channel the bot stores for this Discord server. Confirm?",
ephemeral: true, components: components).ConfigureAwait(false);
}

/// <summary>Executes the purge after confirmation.</summary>
[ComponentInteraction(ConfirmPurgeId)]
public async Task ConfirmPurgeAsync()
{
if (!await EnsureEnabledAsync().ConfigureAwait(false))
{
return;
}

await DeferAsync(ephemeral: true).ConfigureAwait(false);
var scope = scopeFactory.CreateAsyncScope();
try
{
var purge = scope.ServiceProvider.GetRequiredService<IGuildPurgeService>();
await purge.PurgeGuildAsync(Context.Guild.Id).ConfigureAwait(false);
await FollowupAsync("Guild data purged.", ephemeral: true).ConfigureAwait(false);
}
finally
{
await scope.DisposeAsync().ConfigureAwait(false);
}
}

private async Task<bool> EnsureEnabledAsync()
{
if (Context.Guild is null)
Expand Down
50 changes: 50 additions & 0 deletions src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
using RustPlusBot.Features.Workspace.Reconciler;
using RustPlusBot.Persistence;
using RustPlusBot.Persistence.Servers;

namespace RustPlusBot.Features.Workspace.Teardown;

/// <summary>Purges a guild: tears down provisioned channels, then deletes its domain rows.</summary>
/// <param name="context">The bot database context.</param>
/// <param name="servers">Server management (RemoveAsync cascades all per-server rows).</param>
/// <param name="teardown">Removes provisioned Discord channels/categories/messages.</param>
/// <param name="provisioningLock">Held across the whole purge to block concurrent reconciliation.</param>
internal sealed class GuildPurgeService(
BotDbContext context,
IServerService servers,
WorkspaceTeardownService teardown,
IProvisioningLock provisioningLock) : IGuildPurgeService
{
/// <inheritdoc />
public async Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationToken = default)
{
// Hold the per-guild provisioning lock across the ENTIRE purge. Otherwise reconciliation could
// interleave after the teardown step — in particular the self-heal that fires when teardown
// deletes channels — and re-provision resources or add rows into a half-purged guild.
using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false);

// 1) Delete provisioned Discord channels/categories/messages (Discord side + records). Use the
// lock-free core since we already hold the lock (ResetGuildAsync would deadlock re-acquiring).
await teardown.ResetGuildCoreAsync(guildId, cancellationToken).ConfigureAwait(false);

// 2) Remove each server; the RustServer FK cascade clears its per-server rows
// (connection state, command/map settings, switches, alarms, storage monitors, credentials).
var known = await servers.ListAsync(guildId, cancellationToken).ConfigureAwait(false);
foreach (var server in known)
{
await servers.RemoveAsync(guildId, server.Id, cancellationToken).ConfigureAwait(false);
}

// 3) Delete guild-keyed rows that have no cascade FK to RustServer (event subscriptions,
// paired entities, guild settings, FCM registrations).
await context.EventSubscriptions.Where(e => e.GuildId == guildId)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.PairedEntities.Where(p => p.GuildId == guildId)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.GuildSettings.Where(g => g.GuildId == guildId)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.FcmRegistrations.Where(f => f.GuildId == guildId)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
}
11 changes: 11 additions & 0 deletions src/RustPlusBot.Features.Workspace/Teardown/IGuildPurgeService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace RustPlusBot.Features.Workspace.Teardown;

/// <summary>Deletes all of a guild's data: provisioned channels plus its domain rows.</summary>
internal interface IGuildPurgeService
{
/// <summary>Purges one guild back to a just-joined state (channels + servers + guild-scoped rows).</summary>
/// <param name="guildId">The guild to purge.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task that completes when the guild's data has been removed.</returns>
Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ public async Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationTo
public async Task ResetGuildAsync(ulong guildId, CancellationToken cancellationToken = default)
{
using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false);
await ResetGuildCoreAsync(guildId, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Deletes the guild's provisioned resources and records WITHOUT taking the provisioning lock.
/// The caller MUST already hold the guild's <see cref="IProvisioningLock"/> — <see cref="GuildPurgeService"/>
/// uses this so it can hold the lock across the wider purge. External callers use <see cref="ResetGuildAsync"/>.
/// </summary>
/// <param name="guildId">The guild to reset.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task.</returns>
internal async Task ResetGuildCoreAsync(ulong guildId, CancellationToken cancellationToken = default)
{
var categories = await store.GetAllCategoriesAsync(guildId, cancellationToken).ConfigureAwait(false);
foreach (var category in categories)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
services.AddScoped<WorkspaceTeardownService>();
services.AddScoped<IWorkspaceTeardownService>(sp => sp.GetRequiredService<WorkspaceTeardownService>());
services.AddScoped<IServerWorkspaceRemover>(sp => sp.GetRequiredService<WorkspaceTeardownService>());
services.AddScoped<IGuildPurgeService, GuildPurgeService>();

// Options (Host binds the "Workspace" section; default = danger commands off).
services.AddOptions<WorkspaceOptions>();
Expand Down
2 changes: 2 additions & 0 deletions src/RustPlusBot.Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@
.Validate(static o => o.Cooldown > TimeSpan.Zero, "Commands:Cooldown must be positive.")
.ValidateOnStart();
builder.Services.AddCommands();
builder.Services.AddOptions<MaintenanceOptions>()
.Bind(builder.Configuration.GetSection("Workspace"));
builder.Services.AddEvents();
builder.Services.AddPlayers();
builder.Services.AddOptions<MapOptions>()
Expand Down
Loading
Loading