Skip to content

Commit 338d09f

Browse files
HandyS11claude
andauthored
Admin debug/utility commands: /ping, /status, /workspace repair·rebuild·purge, /admin reset-database (#38)
* feat: add DatabaseMaintenanceService (clear all rows, keep schema) * test: exercise FK-cascade path in DatabaseMaintenanceService wipe test * feat: add GuildPurgeService (per-guild data purge) * fix: purge FcmRegistrations in GuildPurgeService (guild-keyed credentials) * feat: add /ping and /status diagnostics commands * feat: add /admin reset-database command * feat: add /workspace repair, rebuild, and purge commands * fix: cap /status server fields under Discord's 25-field embed limit Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address Copilot review on PR #38 (atomicity, N+1, wording, purge lock) - DatabaseMaintenanceService: wipe in a single transaction with defer_foreign_keys so an interruption rolls back instead of leaving a partially-cleared DB; throw on an unexpected table identifier instead of silently skipping it (and reporting a misleading success). - DiagnosticsModule /status: replace the per-server GetStateAsync N+1 with a single IConnectionStore.GetStatesForGuildAsync bulk read + dictionary lookup. - MaintenanceModule: clearer danger-off message for the operator-facing /admin group instead of "Developer commands are disabled". - GuildPurgeService: hold the per-guild provisioning lock across the whole purge (teardown + domain deletes) so concurrent reconciliation — including the self-heal triggered by teardown deleting channels — cannot re-provision into a half-purged guild. Adds a lock-free WorkspaceTeardownService core. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ef4e274 commit 338d09f

18 files changed

Lines changed: 630 additions & 0 deletions

File tree

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
2323

2424
services.AddSingleton<CommandCooldown>();
2525
services.AddSingleton<BotUptime>();
26+
services.AddOptions<MaintenanceOptions>();
2627
services.AddRustPlusBotLocalization();
2728
services.AddItemData();
2829

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace RustPlusBot.Features.Commands;
2+
3+
/// <summary>
4+
/// Gates the dangerous maintenance command (/admin reset-database). Bound to the same "Workspace"
5+
/// config section as WorkspaceOptions, so a single EnableDangerCommands flag governs all danger commands.
6+
/// </summary>
7+
public sealed class MaintenanceOptions
8+
{
9+
/// <summary>Enables the dangerous /admin reset-database command.</summary>
10+
public bool EnableDangerCommands { get; set; }
11+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System.Diagnostics;
2+
using System.Globalization;
3+
using Discord;
4+
using Discord.Interactions;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using RustPlusBot.Features.Commands.Formatting;
7+
using RustPlusBot.Features.Commands.Hosting;
8+
using RustPlusBot.Persistence.Connections;
9+
using RustPlusBot.Persistence.Servers;
10+
11+
namespace RustPlusBot.Features.Commands.Modules;
12+
13+
/// <summary>Read-only diagnostics: /ping and /status.</summary>
14+
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
15+
public sealed class DiagnosticsModule(IServiceScopeFactory scopeFactory)
16+
: InteractionModuleBase<SocketInteractionContext>
17+
{
18+
/// <summary>Reports Discord gateway latency and the REST ack round-trip.</summary>
19+
[SlashCommand("ping", "Show the bot's Discord latency")]
20+
public async Task PingAsync()
21+
{
22+
var stopwatch = Stopwatch.StartNew();
23+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
24+
stopwatch.Stop();
25+
26+
var text = string.Create(CultureInfo.InvariantCulture,
27+
$"Pong! Gateway: {Context.Client.Latency} ms · Response: {stopwatch.ElapsedMilliseconds} ms");
28+
await FollowupAsync(text, ephemeral: true).ConfigureAwait(false);
29+
}
30+
31+
/// <summary>Shows uptime, latency, per-server connection status, and counts.</summary>
32+
[SlashCommand("status", "Show bot health and connection status")]
33+
public async Task StatusAsync()
34+
{
35+
if (Context.Guild is null)
36+
{
37+
await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false);
38+
return;
39+
}
40+
41+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
42+
var scope = scopeFactory.CreateAsyncScope();
43+
await using (scope.ConfigureAwait(false))
44+
{
45+
var uptime = scope.ServiceProvider.GetRequiredService<BotUptime>();
46+
var servers = scope.ServiceProvider.GetRequiredService<IServerService>();
47+
var connections = scope.ServiceProvider.GetRequiredService<IConnectionStore>();
48+
49+
var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false);
50+
var states = (await connections.GetStatesForGuildAsync(Context.Guild.Id).ConfigureAwait(false))
51+
.ToDictionary(state => state.RustServerId);
52+
53+
var embed = new EmbedBuilder()
54+
.WithTitle("Bot status")
55+
.AddField("Uptime", DurationFormat.Compact(uptime.Elapsed), inline: true)
56+
.AddField("Gateway latency",
57+
string.Create(CultureInfo.InvariantCulture, $"{Context.Client.Latency} ms"), inline: true)
58+
.AddField("Guilds",
59+
Context.Client.Guilds.Count.ToString(CultureInfo.InvariantCulture), inline: true)
60+
.AddField("Servers (this guild)",
61+
known.Count.ToString(CultureInfo.InvariantCulture), inline: true);
62+
63+
// Cap server fields so the embed stays under Discord's 25-field limit (4 header fields above).
64+
foreach (var server in known.Take(20))
65+
{
66+
var line = states.TryGetValue(server.Id, out var state)
67+
? string.Create(CultureInfo.InvariantCulture,
68+
$"{state.Status} · {state.PlayerCount?.ToString(CultureInfo.InvariantCulture) ?? "?"} players")
69+
: "unknown";
70+
embed.AddField(server.Name, line);
71+
}
72+
73+
await FollowupAsync(ephemeral: true, embed: embed.Build()).ConfigureAwait(false);
74+
}
75+
}
76+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using Discord;
2+
using Discord.Interactions;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Options;
5+
using RustPlusBot.Persistence.Maintenance;
6+
7+
namespace RustPlusBot.Features.Commands.Modules;
8+
9+
/// <summary>Dangerous, danger-gated bot maintenance commands.</summary>
10+
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
11+
/// <param name="options">Gates the command behind the danger flag.</param>
12+
[Group("admin", "Bot maintenance (dangerous)")]
13+
[RequireUserPermission(GuildPermission.ManageGuild)]
14+
public sealed class MaintenanceModule(
15+
IServiceScopeFactory scopeFactory,
16+
IOptions<MaintenanceOptions> options) : InteractionModuleBase<SocketInteractionContext>
17+
{
18+
/// <summary>Wipes all bot data across all guilds after a typed confirmation.</summary>
19+
/// <param name="confirm">Must equal the literal "RESET" to proceed.</param>
20+
[SlashCommand("reset-database", "Wipe ALL bot data across ALL servers (dangerous)")]
21+
public async Task ResetDatabaseAsync(
22+
[Summary("confirm", "Type RESET to confirm")]
23+
string confirm)
24+
{
25+
if (Context.Guild is null)
26+
{
27+
await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false);
28+
return;
29+
}
30+
31+
if (!options.Value.EnableDangerCommands)
32+
{
33+
await RespondAsync(
34+
"Dangerous maintenance commands are disabled. Set `Workspace:EnableDangerCommands` to enable them.",
35+
ephemeral: true).ConfigureAwait(false);
36+
return;
37+
}
38+
39+
if (!string.Equals(confirm, "RESET", StringComparison.Ordinal))
40+
{
41+
await RespondAsync("Type `RESET` exactly to confirm the database wipe.", ephemeral: true)
42+
.ConfigureAwait(false);
43+
return;
44+
}
45+
46+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
47+
var scope = scopeFactory.CreateAsyncScope();
48+
try
49+
{
50+
var maintenance = scope.ServiceProvider.GetRequiredService<IDatabaseMaintenanceService>();
51+
await maintenance.ClearAllAsync().ConfigureAwait(false);
52+
await FollowupAsync("Database cleared. **Restart the bot** for a clean state.", ephemeral: true)
53+
.ConfigureAwait(false);
54+
}
55+
finally
56+
{
57+
await scope.DisposeAsync().ConfigureAwait(false);
58+
}
59+
}
60+
}

src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ public sealed class WorkspaceAdminModule(
2323
/// <summary>Custom id for the reset confirmation button.</summary>
2424
public const string ConfirmResetId = "workspace:reset:confirm";
2525

26+
/// <summary>Custom id for the rebuild confirmation button.</summary>
27+
public const string ConfirmRebuildId = "workspace:rebuild:confirm";
28+
29+
/// <summary>Custom id for the purge confirmation button.</summary>
30+
public const string ConfirmPurgeId = "workspace:purge:confirm";
31+
2632
/// <summary>Prompts to delete the entire provisioned workspace (dev-gated).</summary>
2733
[SlashCommand("reset", "Delete ALL of the bot's channels and records in this Discord server (dangerous)")]
2834
public async Task ResetAsync()
@@ -98,6 +104,128 @@ await FollowupAsync($"Registered **{name}** and published ServerRegisteredEvent.
98104
}
99105
}
100106

107+
/// <summary>Recreates any missing categories/channels/messages without deleting data.</summary>
108+
[SlashCommand("repair", "Recreate any missing bot channels without deleting data")]
109+
public async Task RepairAsync()
110+
{
111+
if (Context.Guild is null)
112+
{
113+
await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false);
114+
return;
115+
}
116+
117+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
118+
var scope = scopeFactory.CreateAsyncScope();
119+
try
120+
{
121+
var reconciler = scope.ServiceProvider.GetRequiredService<IWorkspaceReconciler>();
122+
await reconciler.HealGuildAsync(Context.Guild.Id).ConfigureAwait(false);
123+
await FollowupAsync("Workspace repaired.", ephemeral: true).ConfigureAwait(false);
124+
}
125+
finally
126+
{
127+
await scope.DisposeAsync().ConfigureAwait(false);
128+
}
129+
}
130+
131+
/// <summary>Prompts to delete and re-provision the entire workspace (dev-gated).</summary>
132+
[SlashCommand("rebuild", "Delete and re-create all the bot's channels here (dangerous)")]
133+
public async Task RebuildAsync()
134+
{
135+
if (!await EnsureEnabledAsync().ConfigureAwait(false))
136+
{
137+
return;
138+
}
139+
140+
var components = new ComponentBuilder()
141+
.WithButton("Confirm rebuild", ConfirmRebuildId, ButtonStyle.Danger)
142+
.Build();
143+
await RespondAsync(
144+
"This deletes every provisioned channel and re-creates them from scratch. Confirm?",
145+
ephemeral: true, components: components).ConfigureAwait(false);
146+
}
147+
148+
/// <summary>Executes the rebuild after confirmation.</summary>
149+
[ComponentInteraction(ConfirmRebuildId)]
150+
public async Task ConfirmRebuildAsync()
151+
{
152+
if (!await EnsureEnabledAsync().ConfigureAwait(false))
153+
{
154+
return;
155+
}
156+
157+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
158+
var scope = scopeFactory.CreateAsyncScope();
159+
try
160+
{
161+
var teardown = scope.ServiceProvider.GetRequiredService<IWorkspaceTeardownService>();
162+
var reconciler = scope.ServiceProvider.GetRequiredService<IWorkspaceReconciler>();
163+
var servers = scope.ServiceProvider.GetRequiredService<IServerService>();
164+
165+
await teardown.ResetGuildAsync(Context.Guild.Id).ConfigureAwait(false);
166+
167+
var result = await reconciler.ReconcileGlobalAsync(Context.Guild.Id).ConfigureAwait(false);
168+
if (result.Status == ReconcileStatus.MissingPermissions)
169+
{
170+
await FollowupAsync(
171+
$"I'm missing required permissions: {string.Join(", ", result.MissingPermissions)}.",
172+
ephemeral: true).ConfigureAwait(false);
173+
return;
174+
}
175+
176+
foreach (var server in await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false))
177+
{
178+
await reconciler.ReconcileServerAsync(Context.Guild.Id, server.Id).ConfigureAwait(false);
179+
}
180+
181+
await FollowupAsync("Workspace rebuilt.", ephemeral: true).ConfigureAwait(false);
182+
}
183+
finally
184+
{
185+
await scope.DisposeAsync().ConfigureAwait(false);
186+
}
187+
}
188+
189+
/// <summary>Prompts to delete all of this guild's data (dev-gated).</summary>
190+
[SlashCommand("purge", "Delete ALL of this server's bot data (servers, settings, channels) (dangerous)")]
191+
public async Task PurgeAsync()
192+
{
193+
if (!await EnsureEnabledAsync().ConfigureAwait(false))
194+
{
195+
return;
196+
}
197+
198+
var components = new ComponentBuilder()
199+
.WithButton("Confirm purge", ConfirmPurgeId, ButtonStyle.Danger)
200+
.Build();
201+
await RespondAsync(
202+
"This deletes every server, setting, and channel the bot stores for this Discord server. Confirm?",
203+
ephemeral: true, components: components).ConfigureAwait(false);
204+
}
205+
206+
/// <summary>Executes the purge after confirmation.</summary>
207+
[ComponentInteraction(ConfirmPurgeId)]
208+
public async Task ConfirmPurgeAsync()
209+
{
210+
if (!await EnsureEnabledAsync().ConfigureAwait(false))
211+
{
212+
return;
213+
}
214+
215+
await DeferAsync(ephemeral: true).ConfigureAwait(false);
216+
var scope = scopeFactory.CreateAsyncScope();
217+
try
218+
{
219+
var purge = scope.ServiceProvider.GetRequiredService<IGuildPurgeService>();
220+
await purge.PurgeGuildAsync(Context.Guild.Id).ConfigureAwait(false);
221+
await FollowupAsync("Guild data purged.", ephemeral: true).ConfigureAwait(false);
222+
}
223+
finally
224+
{
225+
await scope.DisposeAsync().ConfigureAwait(false);
226+
}
227+
}
228+
101229
private async Task<bool> EnsureEnabledAsync()
102230
{
103231
if (Context.Guild is null)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using RustPlusBot.Features.Workspace.Reconciler;
3+
using RustPlusBot.Persistence;
4+
using RustPlusBot.Persistence.Servers;
5+
6+
namespace RustPlusBot.Features.Workspace.Teardown;
7+
8+
/// <summary>Purges a guild: tears down provisioned channels, then deletes its domain rows.</summary>
9+
/// <param name="context">The bot database context.</param>
10+
/// <param name="servers">Server management (RemoveAsync cascades all per-server rows).</param>
11+
/// <param name="teardown">Removes provisioned Discord channels/categories/messages.</param>
12+
/// <param name="provisioningLock">Held across the whole purge to block concurrent reconciliation.</param>
13+
internal sealed class GuildPurgeService(
14+
BotDbContext context,
15+
IServerService servers,
16+
WorkspaceTeardownService teardown,
17+
IProvisioningLock provisioningLock) : IGuildPurgeService
18+
{
19+
/// <inheritdoc />
20+
public async Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationToken = default)
21+
{
22+
// Hold the per-guild provisioning lock across the ENTIRE purge. Otherwise reconciliation could
23+
// interleave after the teardown step — in particular the self-heal that fires when teardown
24+
// deletes channels — and re-provision resources or add rows into a half-purged guild.
25+
using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false);
26+
27+
// 1) Delete provisioned Discord channels/categories/messages (Discord side + records). Use the
28+
// lock-free core since we already hold the lock (ResetGuildAsync would deadlock re-acquiring).
29+
await teardown.ResetGuildCoreAsync(guildId, cancellationToken).ConfigureAwait(false);
30+
31+
// 2) Remove each server; the RustServer FK cascade clears its per-server rows
32+
// (connection state, command/map settings, switches, alarms, storage monitors, credentials).
33+
var known = await servers.ListAsync(guildId, cancellationToken).ConfigureAwait(false);
34+
foreach (var server in known)
35+
{
36+
await servers.RemoveAsync(guildId, server.Id, cancellationToken).ConfigureAwait(false);
37+
}
38+
39+
// 3) Delete guild-keyed rows that have no cascade FK to RustServer (event subscriptions,
40+
// paired entities, guild settings, FCM registrations).
41+
await context.EventSubscriptions.Where(e => e.GuildId == guildId)
42+
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
43+
await context.PairedEntities.Where(p => p.GuildId == guildId)
44+
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
45+
await context.GuildSettings.Where(g => g.GuildId == guildId)
46+
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
47+
await context.FcmRegistrations.Where(f => f.GuildId == guildId)
48+
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
49+
}
50+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace RustPlusBot.Features.Workspace.Teardown;
2+
3+
/// <summary>Deletes all of a guild's data: provisioned channels plus its domain rows.</summary>
4+
internal interface IGuildPurgeService
5+
{
6+
/// <summary>Purges one guild back to a just-joined state (channels + servers + guild-scoped rows).</summary>
7+
/// <param name="guildId">The guild to purge.</param>
8+
/// <param name="cancellationToken">A cancellation token.</param>
9+
/// <returns>A task that completes when the guild's data has been removed.</returns>
10+
Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationToken = default);
11+
}

src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ public async Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationTo
2424
public async Task ResetGuildAsync(ulong guildId, CancellationToken cancellationToken = default)
2525
{
2626
using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false);
27+
await ResetGuildCoreAsync(guildId, cancellationToken).ConfigureAwait(false);
28+
}
29+
30+
/// <summary>
31+
/// Deletes the guild's provisioned resources and records WITHOUT taking the provisioning lock.
32+
/// The caller MUST already hold the guild's <see cref="IProvisioningLock"/> — <see cref="GuildPurgeService"/>
33+
/// uses this so it can hold the lock across the wider purge. External callers use <see cref="ResetGuildAsync"/>.
34+
/// </summary>
35+
/// <param name="guildId">The guild to reset.</param>
36+
/// <param name="cancellationToken">A cancellation token.</param>
37+
/// <returns>A task.</returns>
38+
internal async Task ResetGuildCoreAsync(ulong guildId, CancellationToken cancellationToken = default)
39+
{
2740
var categories = await store.GetAllCategoriesAsync(guildId, cancellationToken).ConfigureAwait(false);
2841
foreach (var category in categories)
2942
{

src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
5151
services.AddScoped<WorkspaceTeardownService>();
5252
services.AddScoped<IWorkspaceTeardownService>(sp => sp.GetRequiredService<WorkspaceTeardownService>());
5353
services.AddScoped<IServerWorkspaceRemover>(sp => sp.GetRequiredService<WorkspaceTeardownService>());
54+
services.AddScoped<IGuildPurgeService, GuildPurgeService>();
5455

5556
// Options (Host binds the "Workspace" section; default = danger commands off).
5657
services.AddOptions<WorkspaceOptions>();

src/RustPlusBot.Host/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@
7171
.Validate(static o => o.Cooldown > TimeSpan.Zero, "Commands:Cooldown must be positive.")
7272
.ValidateOnStart();
7373
builder.Services.AddCommands();
74+
builder.Services.AddOptions<MaintenanceOptions>()
75+
.Bind(builder.Configuration.GetSection("Workspace"));
7476
builder.Services.AddEvents();
7577
builder.Services.AddPlayers();
7678
builder.Services.AddOptions<MapOptions>()

0 commit comments

Comments
 (0)