fix(discord): keep slash commands deployed at the application limit (#105280)

* fix(discord): reconcile commands at the application cap

* docs(changelog): note Discord command cap fix

* chore: keep release changelog unchanged

* fix(discord): narrow command deployment errors
This commit is contained in:
Peter Steinberger
2026-07-12 11:37:29 +01:00
committed by GitHub
parent 75f0960b71
commit 4249cdb8fe
2 changed files with 106 additions and 8 deletions
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { Client, ComponentRegistry, type AnyListener } from "./client.js";
import { BaseCommand } from "./commands.js";
import { Button, StringSelectMenu, parseCustomId } from "./components.js";
import { DiscordError } from "./rest.js";
import { attachRestMock, createInternalTestClient } from "./test-builders.test-support.js";
function createDeferred<T = void>(): {
@@ -239,6 +240,78 @@ describe("Client.deployCommands", () => {
expect(deleteRequest).not.toHaveBeenCalled();
});
it("bulk overwrites when a capped application cannot create a replacement", async () => {
const retainedCommands = Array.from({ length: 99 }, (_, index) =>
createTestCommand({ name: `retained-${index}` }),
);
const replacement = createTestCommand({ name: "replacement" });
const client = createInternalTestClient([...retainedCommands, replacement]);
const existing = [
...retainedCommands.map((command, index) =>
Object.assign(command.serialize(), {
id: `retained-id-${index}`,
application_id: "app1",
}),
),
Object.assign(createTestCommand({ name: "stale" }).serialize(), {
id: "stale-id",
application_id: "app1",
}),
];
let deployedCount = existing.length;
const operations: string[] = [];
const get = vi.fn(async () => existing);
const post = vi.fn(async () => {
if (deployedCount >= 100) {
throw new DiscordError(new Response(null, { status: 400 }), {
message: "Maximum number of application commands reached (100).",
code: 30032,
});
}
deployedCount += 1;
operations.push("post");
});
const put = vi.fn(async () => {
deployedCount = 100;
operations.push("put");
});
const deleteRequest = vi.fn(async () => undefined);
attachRestMock(client, { get, post, put, delete: deleteRequest });
await client.deployCommands({ mode: "reconcile" });
expect(deleteRequest).not.toHaveBeenCalled();
expect(post).toHaveBeenCalledWith(Routes.applicationCommands("app1"), {
body: replacement.serialize(),
});
expect(put).toHaveBeenCalledWith(Routes.applicationCommands("app1"), {
body: [...retainedCommands, replacement].map((command) => command.serialize()),
});
expect(operations).toEqual(["put"]);
expect(deployedCount).toBe(100);
});
it("keeps stale commands when a replacement create fails below the cap", async () => {
const client = createInternalTestClient([createTestCommand({ name: "replacement" })]);
const get = vi.fn(async () => [
Object.assign(createTestCommand({ name: "stale" }).serialize(), {
id: "stale-id",
application_id: "app1",
}),
]);
const post = vi.fn(async () => {
throw new Error("Discord unavailable");
});
const deleteRequest = vi.fn(async () => undefined);
attachRestMock(client, { get, post, delete: deleteRequest });
await expect(client.deployCommands({ mode: "reconcile" })).rejects.toThrow(
"Discord unavailable",
);
expect(deleteRequest).not.toHaveBeenCalled();
});
it("patches changed option localization maps", async () => {
const client = createInternalTestClient([
createTestCommand({
@@ -22,6 +22,8 @@ export type DeployCommandOptions = {
type SerializedCommand = ReturnType<BaseCommand["serialize"]>;
const DISCORD_APPLICATION_COMMAND_LIMIT_REACHED = 30032;
/**
* Per-`command-deploy-cache.json` path async mutex. `server-channels.ts` can
* start several Discord deployers concurrently in the same Node.js process;
@@ -133,17 +135,31 @@ export class DiscordCommandDeployer {
private async reconcileGlobalCommands(desired: SerializedCommand[]) {
const existing = await this.getCommands();
const existingByKey = new Map(existing.map((command) => [stableCommandKey(command), command]));
const desiredKeys = new Set<string>();
for (const command of desired) {
const key = stableCommandKey(command as APIApplicationCommand);
desiredKeys.add(key);
const desiredCommands = desired.map((command) => ({
command,
key: stableCommandKey(command as APIApplicationCommand),
}));
const desiredKeys = new Set(desiredCommands.map(({ key }) => key));
for (const { command, key } of desiredCommands) {
const current = existingByKey.get(key);
if (!current) {
await createApplicationCommand(this.rest, this.params.clientId, command);
if (current && !commandsEqual(current, command)) {
await editApplicationCommand(this.rest, this.params.clientId, current.id, command);
}
}
for (const { command, key } of desiredCommands) {
if (existingByKey.has(key)) {
continue;
}
if (!commandsEqual(current, command)) {
await editApplicationCommand(this.rest, this.params.clientId, current.id, command);
try {
await createApplicationCommand(this.rest, this.params.clientId, command);
} catch (error) {
if (!isApplicationCommandLimitError(error)) {
throw error;
}
// Reconcile cannot create before deleting at Discord's hard cap. Bulk
// overwrite replaces the complete set without an unsafe delete gap.
await overwriteApplicationCommands(this.rest, this.params.clientId, desired);
return;
}
}
for (const command of existing) {
@@ -283,6 +299,15 @@ function stableCommandKey(command: Pick<APIApplicationCommand, "name" | "type">)
return `${command.type ?? ApplicationCommandType.ChatInput}:${command.name}`;
}
function isApplicationCommandLimitError(error: unknown): boolean {
return (
error !== null &&
typeof error === "object" &&
"discordCode" in error &&
error.discordCode === DISCORD_APPLICATION_COMMAND_LIMIT_REACHED
);
}
function comparableCommand(value: unknown): unknown {
if (!value || typeof value !== "object") {
return value;