From 3c3fd8c386a6d6396f3fb43b6a3abd95a0937853 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Wed, 25 Mar 2026 09:19:46 -0400 Subject: [PATCH] Discord: log rejected native command deploy failures (#54118) Merged via squash. Prepared head SHA: be250f96204ed6dc755c10d2b9640f7dd49bc70c Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + .../monitor/native-command.options.test.ts | 70 ++++++++++- .../discord/src/monitor/native-command.ts | 42 ++++++- .../discord/src/monitor/provider.test.ts | 54 ++++++++- extensions/discord/src/monitor/provider.ts | 110 +++++++++++++++++- 5 files changed, 267 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 020e210e208..c1f0fd6dc99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Marketplace/agents: correct the ClawHub skill URL in agent docs and stream marketplace archive downloads to disk so installs avoid excess memory use and fail cleanly on empty responses. (#54160) Thanks @QuinnH496. - Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman. - Docs/IRC: fix five `json55` code-fence typos in the IRC channel examples so Mintlify applies JSON5 syntax highlighting correctly. (#50842) Thanks @Hollychou924. +- Discord/commands: trim overlong slash-command descriptions to Discord's 100-character limit and map rejected deploy indexes from Discord validation payloads back to command names/descriptions, so deploys stop failing on long descriptions and startup logs identify the rejected commands. (#54118) thanks @huntharo ## 2026.3.23 diff --git a/extensions/discord/src/monitor/native-command.options.test.ts b/extensions/discord/src/monitor/native-command.options.test.ts index 76f66ac7854..24594985a1b 100644 --- a/extensions/discord/src/monitor/native-command.options.test.ts +++ b/extensions/discord/src/monitor/native-command.options.test.ts @@ -1,6 +1,31 @@ import { ChannelType } from "discord-api-types/v10"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js"; + +const { logVerboseMock } = vi.hoisted(() => ({ + logVerboseMock: vi.fn(), +})); +const { loggerWarnMock } = vi.hoisted(() => ({ + loggerWarnMock: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/runtime-env", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/runtime-env", + ); + return { + ...actual, + createSubsystemLogger: () => ({ + child: vi.fn(), + info: vi.fn(), + error: vi.fn(), + warn: loggerWarnMock, + debug: vi.fn(), + }), + logVerbose: logVerboseMock, + }; +}); + let listNativeCommandSpecs: typeof import("../../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs; let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand; let createNoopThreadBindingManager: typeof import("./thread-bindings.js").createNoopThreadBindingManager; @@ -77,6 +102,11 @@ describe("createDiscordNativeCommand option wiring", () => { ({ createNoopThreadBindingManager } = await import("./thread-bindings.js")); }); + beforeEach(() => { + logVerboseMock.mockReset(); + loggerWarnMock.mockReset(); + }); + it("uses autocomplete for /acp action so inline action values are accepted", async () => { const command = createNativeCommand("acp"); const action = requireOption(command, "action"); @@ -168,4 +198,42 @@ describe("createDiscordNativeCommand option wiring", () => { expect(respond).toHaveBeenCalledWith([]); }); + + it("truncates Discord command and option descriptions to Discord's limit", () => { + const longDescription = "x".repeat(140); + const cfg = {} as ReturnType; + const discordConfig = {} as NonNullable["discord"]; + const command = createDiscordNativeCommand({ + command: { + name: "longdesc", + description: longDescription, + acceptsArgs: true, + args: [ + { + name: "input", + description: longDescription, + type: "string", + required: false, + }, + ], + }, + cfg, + discordConfig, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); + + expect(command.description).toHaveLength(100); + expect(command.description).toBe("x".repeat(100)); + expect(requireOption(command, "input").description).toHaveLength(100); + expect(requireOption(command, "input").description).toBe("x".repeat(100)); + expect(loggerWarnMock).toHaveBeenCalledWith( + expect.stringContaining("truncating native command description (command:longdesc)"), + ); + expect(loggerWarnMock).toHaveBeenCalledWith( + expect.stringContaining("truncating native command description (command:longdesc arg:input)"), + ); + }); }); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 49b2c59d144..e781ee393bf 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -81,6 +81,27 @@ import { resolveDiscordThreadParentInfo } from "./threading.js"; type DiscordConfig = NonNullable["discord"]; const log = createSubsystemLogger("discord/native-command"); +// Discord application command and option descriptions are limited to 1-100 chars. +// https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure +const DISCORD_COMMAND_DESCRIPTION_MAX = 100; + +function truncateDiscordCommandDescription(params: { value: string; label: string }): string { + const { value, label } = params; + if (value.length <= DISCORD_COMMAND_DESCRIPTION_MAX) { + return value; + } + log.warn( + `discord: truncating native command description (${label}) from ${value.length} to ${DISCORD_COMMAND_DESCRIPTION_MAX}: ${JSON.stringify(value)}`, + ); + return value.slice(0, DISCORD_COMMAND_DESCRIPTION_MAX); +} + +function resolveDiscordCommandLogLabel(command: ChatCommandDefinition): string { + if (typeof command.nativeName === "string" && command.nativeName.trim().length > 0) { + return command.nativeName; + } + return command.key; +} function resolveDiscordNativeCommandAllowlistAccess(params: { cfg: OpenClawConfig; @@ -124,6 +145,7 @@ function buildDiscordCommandOptions(params: { ) => Promise<{ provider?: string; model?: string } | null>; }): CommandOptions | undefined { const { command, cfg, authorizeChoiceContext, resolveChoiceContext } = params; + const commandLabel = resolveDiscordCommandLogLabel(command); const args = command.args; if (!args || args.length === 0) { return undefined; @@ -133,7 +155,10 @@ function buildDiscordCommandOptions(params: { if (arg.type === "number") { return { name: arg.name, - description: arg.description, + description: truncateDiscordCommandDescription({ + value: arg.description, + label: `command:${commandLabel} arg:${arg.name}`, + }), type: ApplicationCommandOptionType.Number, required, }; @@ -141,7 +166,10 @@ function buildDiscordCommandOptions(params: { if (arg.type === "boolean") { return { name: arg.name, - description: arg.description, + description: truncateDiscordCommandDescription({ + value: arg.description, + label: `command:${commandLabel} arg:${arg.name}`, + }), type: ApplicationCommandOptionType.Boolean, required, }; @@ -192,7 +220,10 @@ function buildDiscordCommandOptions(params: { : undefined; return { name: arg.name, - description: arg.description, + description: truncateDiscordCommandDescription({ + value: arg.description, + label: `command:${commandLabel} arg:${arg.name}`, + }), type: ApplicationCommandOptionType.String, required, choices, @@ -517,7 +548,10 @@ export function createDiscordNativeCommand(params: { return new (class extends Command { name = command.name; - description = command.description; + description = truncateDiscordCommandDescription({ + value: command.description, + label: `command:${command.name}`, + }); defer = true; ephemeral = ephemeralDefault; options = options; diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 90f3d09b99b..cfe3f920ab6 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -37,6 +37,7 @@ const { } = getProviderMonitorTestMocks(); let monitorDiscordProvider: typeof import("./provider.js").monitorDiscordProvider; +let providerTesting: typeof import("./provider.js").__testing; function createConfigWithDiscordAccount(overrides: Record = {}): OpenClawConfig { return { @@ -129,7 +130,7 @@ describe("monitorDiscordProvider", () => { vi.doMock("../token.js", () => ({ normalizeDiscordToken: (value?: string) => value, })); - ({ monitorDiscordProvider } = await import("./provider.js")); + ({ monitorDiscordProvider, __testing: providerTesting } = await import("./provider.js")); }); beforeEach(() => { @@ -552,6 +553,57 @@ describe("monitorDiscordProvider", () => { ); }); + it("formats rejected Discord deploy entries with command details", () => { + const details = providerTesting.formatDiscordDeployErrorDetails({ + status: 400, + discordCode: 50035, + rawBody: { + code: 50035, + message: "Invalid Form Body", + errors: { + 63: { + description: { + _errors: [{ code: "BASE_TYPE_MAX_LENGTH", message: "Must be 100 or fewer." }], + }, + }, + 65: { + description: { + _errors: [{ code: "BASE_TYPE_MAX_LENGTH", message: "Must be 100 or fewer." }], + }, + }, + 66: { + description: { + _errors: [{ code: "BASE_TYPE_MAX_LENGTH", message: "Must be 100 or fewer." }], + }, + }, + 67: { + description: { + _errors: [{ code: "BASE_TYPE_MAX_LENGTH", message: "Must be 100 or fewer." }], + }, + }, + }, + }, + deployRequestBody: Array.from({ length: 68 }, (_entry, index) => ({ + name: `command-${index}`, + description: `description-${index}`, + })), + }); + + expect(details).toContain("status=400"); + expect(details).toContain("code=50035"); + expect(details).toContain("rejected="); + expect(details).toContain( + '#63 fields=description name=command-63 description="description-63"', + ); + expect(details).toContain( + '#65 fields=description name=command-65 description="description-65"', + ); + expect(details).toContain( + '#66 fields=description name=command-66 description="description-66"', + ); + expect(details).not.toContain("command-67"); + }); + it("configures Carbon native deploy by default", async () => { await monitorDiscordProvider({ config: baseConfig(), diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 96fadc18625..3cc69a23561 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -312,8 +312,10 @@ async function deployDiscordCommands(params: { } return result; } catch (err) { + attachDiscordDeployRequestBody(err, body); + const details = formatDiscordDeployErrorDetails(err); params.runtime.error?.( - `discord startup [${accountId}] deploy-rest:put:error ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt} error=${formatErrorMessage(err)}`, + `discord startup [${accountId}] deploy-rest:put:error ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt} error=${formatErrorMessage(err)}${details}`, ); throw err; } @@ -400,13 +402,108 @@ function logDiscordStartupPhase(params: { `discord startup [${params.accountId}] ${params.phase} ${elapsedMs}ms${suffix ? ` ${suffix}` : ""}`, ); } + +const DISCORD_DEPLOY_REJECTED_ENTRY_LIMIT = 3; + +type DiscordDeployErrorLike = { + status?: unknown; + discordCode?: unknown; + rawBody?: unknown; + deployRequestBody?: unknown; +}; + +function attachDiscordDeployRequestBody(err: unknown, body: unknown) { + if (!err || typeof err !== "object" || body === undefined) { + return; + } + const deployErr = err as DiscordDeployErrorLike; + if (deployErr.deployRequestBody === undefined) { + deployErr.deployRequestBody = body; + } +} + +function stringifyDiscordDeployField(value: unknown): string { + if (typeof value === "string") { + return JSON.stringify(value); + } + try { + return JSON.stringify(value); + } catch { + return inspect(value, { depth: 2, breakLength: 120 }); + } +} + +function readDiscordDeployRejectedFields(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string").slice(0, 6); + } + if (!value || typeof value !== "object") { + return []; + } + return Object.keys(value).slice(0, 6); +} + +function resolveDiscordRejectedDeployEntriesSource( + rawBody: unknown, +): Record | null { + if (!rawBody || typeof rawBody !== "object") { + return null; + } + const payload = rawBody as { errors?: unknown }; + const errors = payload.errors && typeof payload.errors === "object" ? payload.errors : undefined; + const source = errors ?? rawBody; + return source && typeof source === "object" ? (source as Record) : null; +} + +function formatDiscordRejectedDeployEntries(params: { + rawBody: unknown; + requestBody: unknown; +}): string[] { + const requestBody = Array.isArray(params.requestBody) ? params.requestBody : null; + const rejectedEntriesSource = resolveDiscordRejectedDeployEntriesSource(params.rawBody); + if (!rejectedEntriesSource || !requestBody || requestBody.length === 0) { + return []; + } + const rawEntries = Object.entries(rejectedEntriesSource).filter(([key]) => /^\d+$/.test(key)); + return rawEntries.slice(0, DISCORD_DEPLOY_REJECTED_ENTRY_LIMIT).flatMap(([key, value]) => { + const index = Number.parseInt(key, 10); + if (!Number.isFinite(index) || index < 0 || index >= requestBody.length) { + return []; + } + const command = requestBody[index]; + if (!command || typeof command !== "object") { + return [`#${index} fields=${readDiscordDeployRejectedFields(value).join("|") || "unknown"}`]; + } + const payload = command as { + name?: unknown; + description?: unknown; + options?: unknown; + }; + const parts = [ + `#${index}`, + `fields=${readDiscordDeployRejectedFields(value).join("|") || "unknown"}`, + ]; + if (typeof payload.name === "string" && payload.name.trim().length > 0) { + parts.push(`name=${payload.name}`); + } + if (payload.description !== undefined) { + parts.push(`description=${stringifyDiscordDeployField(payload.description)}`); + } + if (Array.isArray(payload.options) && payload.options.length > 0) { + parts.push(`options=${payload.options.length}`); + } + return [parts.join(" ")]; + }); +} + function formatDiscordDeployErrorDetails(err: unknown): string { if (!err || typeof err !== "object") { return ""; } - const status = (err as { status?: unknown }).status; - const discordCode = (err as { discordCode?: unknown }).discordCode; - const rawBody = (err as { rawBody?: unknown }).rawBody; + const status = (err as DiscordDeployErrorLike).status; + const discordCode = (err as DiscordDeployErrorLike).discordCode; + const rawBody = (err as DiscordDeployErrorLike).rawBody; + const requestBody = (err as DiscordDeployErrorLike).deployRequestBody; const details: string[] = []; if (typeof status === "number") { details.push(`status=${status}`); @@ -428,6 +525,10 @@ function formatDiscordDeployErrorDetails(err: unknown): string { details.push(`body=${trimmed}`); } } + const rejectedEntries = formatDiscordRejectedDeployEntries({ rawBody, requestBody }); + if (rejectedEntries.length > 0) { + details.push(`rejected=${rejectedEntries.join("; ")}`); + } return details.length > 0 ? ` (${details.join(", ")})` : ""; } @@ -1058,4 +1159,5 @@ export const __testing = { resolveDefaultGroupPolicy, resolveDiscordRestFetch, resolveThreadBindingsEnabled: resolveThreadBindingsEnabledForTesting, + formatDiscordDeployErrorDetails, };