diff --git a/extensions/discord/src/internal/client.test.ts b/extensions/discord/src/internal/client.test.ts index cd075da5efcf..55c5cb61997e 100644 --- a/extensions/discord/src/internal/client.test.ts +++ b/extensions/discord/src/internal/client.test.ts @@ -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(): { @@ -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({ diff --git a/extensions/discord/src/internal/command-deploy.ts b/extensions/discord/src/internal/command-deploy.ts index 9989d783b82a..88f47ce9ad5f 100644 --- a/extensions/discord/src/internal/command-deploy.ts +++ b/extensions/discord/src/internal/command-deploy.ts @@ -22,6 +22,8 @@ export type DeployCommandOptions = { type SerializedCommand = ReturnType; +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(); - 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) 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;