mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
committed by
GitHub
parent
75f0960b71
commit
4249cdb8fe
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user