Skip to content

Commit 63a0d17

Browse files
committed
Only deploy commands when they have actually changed
The container's CMD is `validate && deploy && start`, so every restart bulk overwrote every global command — needless API traffic, and a rate-limit risk during a crash loop. Global command propagation is also not instant, so redeploying identical payloads on every boot bought nothing. deploy-commands.ts now fingerprints what it is about to send (the global payload, the owner-guild payload, the client id and the guild id) and stores the hash in the existing Keyv/SQLite store. If the fingerprint matches the last successful deploy it skips entirely. `--force` / `-f` overrides, and `--clear` drops the stored fingerprint so the next deploy runs. setCommands() now reports whether the calls succeeded, and the fingerprint is recorded only if they did. It previously caught and logged errors while returning normally, so without that the first failed deploy would have been remembered as successful and never retried. Verified end to end against a bogus token: 1. failed deploy -> nothing recorded, "the next run will retry" 2. run again -> retries, does not skip 3. fingerprint seeded as a success -> skips, zero API calls 4. --force -> deploys anyway 5. BOT_GUILD_ID changed -> fingerprint differs, deploys 6. a command description edited -> fingerprint differs, deploys 7. edit reverted -> skips again Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE
1 parent b049b11 commit 63a0d17

2 files changed

Lines changed: 39 additions & 3 deletions

File tree

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ COPY --link . /app
2020
# Setup some default files
2121
RUN touch settings.sqlite3
2222

23-
# Refresh commands when starting the bot
23+
# Validate config, then deploy commands only if they changed since the last
24+
# start (see scripts/deploy-commands.ts), then run the bot
2425
CMD ["sh", "-c", "bun run validate && bun run deploy && bun run start"]

scripts/deploy-commands.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
1+
import {createHash} from "node:crypto";
12
import path from "node:path";
23
import {fileURLToPath} from "node:url";
34
import {REST, type RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js";
45
import {API} from "@discordjs/core";
56
import {loadCommands} from "../src/framework";
7+
import {globalDB} from "../src/db";
68
import "dotenv/config";
79

810

911
// Check CLI arguments for clear flag
1012
const shouldClear = process.argv.includes("--clear") || process.argv.includes("-c");
13+
const shouldForce = process.argv.includes("--force") || process.argv.includes("-f");
14+
15+
/**
16+
* The container runs this on every start, so an unguarded deploy meant a bulk
17+
* overwrite of every global command on every restart — needless API traffic,
18+
* and a rate-limit risk during a crash loop. The fingerprint covers what is
19+
* sent and where, so a redeploy happens exactly when one of those changes.
20+
*/
21+
const FINGERPRINT_KEY = "deployedCommandsFingerprint";
22+
23+
const fingerprintOf = (global: unknown, guild: unknown) => createHash("sha256")
24+
.update(JSON.stringify({global, guild, clientId: process.env.BOT_CLIENT_ID, guildId: process.env.BOT_GUILD_ID}))
25+
.digest("hex");
1126

1227
// Setup file paths
1328
const __filename = fileURLToPath(import.meta.url);
@@ -17,7 +32,10 @@ const __dirname = path.dirname(__filename);
1732
const rest = new REST().setToken(process.env.BOT_TOKEN!);
1833
const api = new API(rest);
1934

20-
async function setCommands(globalCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[], guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[]) {
35+
/** Returns whether every part that was attempted succeeded. */
36+
async function setCommands(globalCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[], guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[]): Promise<boolean> {
37+
let ok = true;
38+
2139
// Deploy global commands
2240
try {
2341
console.log(`\n🚀 Started ${shouldClear ? "clearing" : "registering"} global application commands...`);
@@ -26,6 +44,7 @@ async function setCommands(globalCommands: RESTPostAPIChatInputApplicationComman
2644
}
2745
catch (error) {
2846
console.error(`❌ Failed to ${shouldClear ? "clear" : "register"} global commands:`, error);
47+
ok = false;
2948
}
3049

3150
// Deploy guild commands (owner commands)
@@ -37,13 +56,15 @@ async function setCommands(globalCommands: RESTPostAPIChatInputApplicationComman
3756
}
3857
catch (error) {
3958
console.error(`❌ Failed to ${shouldClear ? "clear" : "register"} guild commands:`, error);
59+
ok = false;
4060
}
4161
}
4262
else if (!process.env.BOT_GUILD_ID) {
4363
console.log(`⚠️ BOT_GUILD_ID not set - skipping owner command ${shouldClear ? "clearing" : "deployment"}`);
4464
}
4565

4666
console.log(`\n🎉 Command ${shouldClear ? "clearing" : "deployment"} complete!`);
67+
return ok;
4768
}
4869

4970
if (!shouldClear) {
@@ -66,9 +87,23 @@ if (!shouldClear) {
6687
}
6788

6889
console.log(`📁 Loaded ${commands.length} global commands and ${ownerCommands.length} owner commands`);
69-
await setCommands(commands, ownerCommands);
90+
91+
const fingerprint = fingerprintOf(commands, ownerCommands);
92+
const deployed = await globalDB.get(FINGERPRINT_KEY);
93+
94+
if (deployed === fingerprint && !shouldForce) {
95+
console.log("\n⏭️ Commands are unchanged since the last deploy - skipping. Use --force to deploy anyway.");
96+
}
97+
else {
98+
// Only remember the fingerprint if everything actually landed, so a
99+
// failed deploy retries on the next start instead of being skipped.
100+
const ok = await setCommands(commands, ownerCommands);
101+
if (ok) await globalDB.set(FINGERPRINT_KEY, fingerprint);
102+
else console.log("⚠️ Not recording the fingerprint; the next run will retry.");
103+
}
70104
}
71105
else {
72106
console.log("🗑️ Clearing all commands...");
73107
await setCommands([], []);
108+
await globalDB.delete(FINGERPRINT_KEY);
74109
}

0 commit comments

Comments
 (0)