fix(discord): normalize CJK/whitespace in slash-command description comparison to prevent spurious reconcile PATCH + 429 (#76588)

* fix(discord): normalize whitespace in command description comparison

Discord server-side storage collapses consecutive whitespace and removes
whitespace between adjacent CJK characters when persisting slash-command
descriptions. Our locally-serialized desired descriptors keep the original
whitespace, so commandsEqual returned false on every startup for any
deployment with multi-line or CJK-heavy descriptions.

This caused reconcile to issue a PATCH for every such command on every
gateway restart. Under Discord's per-application rate limit that quickly
produced a burst of 429s and some commands silently failed to register
until the next restart — a recurring symptom behind several Discord deploy
429 reports (#75341, #75888).

Fix: apply the same two normalizations to description strings in
comparableCommand — collapse runs of whitespace to a single space, then
drop whitespace between CJK boundary characters — before JSON-based
equality. This mirrors Discord's storage semantics so round-tripped
descriptions compare equal.

Adds command-deploy.test.ts with regression coverage for:
- server-side default fields ignored (dm_permission, nsfw, version, ids)
- required:false treated as absent (existing behavior)
- CJK descriptions with \n separators normalized
- mixed CJK/ASCII descriptions with consecutive whitespace
- substantive description diffs still flagged
- ASCII whitespace normalization

* fix(discord): normalize localized command descriptions

* fix(discord): ignore null localization maps in command reconcile

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Shuxin Zheng
2026-05-03 23:22:41 +08:00
committed by GitHub
parent 38eea33062
commit b8bc8423d2
3 changed files with 259 additions and 1 deletions

View File

@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- Control UI/Sessions: avoid full `sessions.list` reloads for chat-turn `sessions.changed` payloads, so large session stores no longer add multi-second delays while chat responses are being delivered. (#76676) Thanks @VACInc.
- Discord/status: honor explicit `messages.statusReactions.enabled: true` in tool-only guild channels so queued ack reactions can progress through thinking/done lifecycle reactions instead of stopping at the initial emoji. Thanks @Marvinthebored.
- Discord/native commands: compare Discord-normalized slash-command descriptions and localized descriptions during reconcile so CJK or multiline command text no longer triggers redundant startup PATCH bursts and rate-limit 429s. Fixes #76587. Thanks @zhengsx.
- Agents/OpenAI: omit Chat Completions `reasoning_effort` for `gpt-5.4-mini` only when function tools are present while preserving tool-free Chat and Responses reasoning support, preventing Telegram-routed fallback runs from hanging after OpenAI rejects tool payloads. Fixes #76176. Thanks @ThisIsAdilah and @chinar-amrutkar.
- Telegram: reuse the successful startup `getMe` probe for grammY polling startup and continue into `getUpdates` after recoverable `deleteWebhook` cleanup failures, reducing high-latency Bot API control-plane calls before long polling starts. Refs #76388. Thanks @jackiedepp.
- Agents/models: forward model `maxTokens` as the default output-token limit for OpenAI-compatible Responses and Completions transports when no runtime override is provided, preventing provider defaults from silently truncating larger outputs. (#76645) Thanks @joeyfrasier.

View File

@@ -0,0 +1,197 @@
import type { APIApplicationCommand } from "discord-api-types/v10";
import { describe, expect, test } from "vitest";
import { __testing } from "./command-deploy.js";
const { commandsEqual } = __testing;
/**
* Regression tests for Discord slash-command reconcile/deploy equality.
*
* These protect against a class of bugs where Discord's server-side storage
* normalization causes our desired descriptor to re-compare unequal to the
* command Discord returns, which leads to a spurious `PATCH` on every
* gateway startup and, under the per-application rate limit, a cascade of
* `429` responses that silently drop some commands until the next restart.
*/
describe("commandsEqual", () => {
// Shape of what Discord returns on `GET /applications/{appId}/commands`.
// Fields like `version`, `dm_permission`, `nsfw`, `application_id` are
// always present on the server side but absent from our locally-serialized
// desired descriptors — they must therefore be ignored by the comparator.
function currentFromDiscord(
overrides: Partial<APIApplicationCommand> = {},
): APIApplicationCommand {
return {
id: "cmd-1",
application_id: "app",
type: 1,
name: "ping",
description: "ping the bot",
version: "v1",
default_member_permissions: null,
dm_permission: true,
nsfw: false,
...overrides,
} as APIApplicationCommand;
}
// Shape of what a `BaseCommand.serialize()` produces locally.
function desiredFromLocal(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
name: "ping",
description: "ping the bot",
type: 1,
default_member_permissions: null,
...overrides,
};
}
test("ignores Discord server-side default fields (dm_permission, nsfw, version, id, application_id)", () => {
expect(commandsEqual(currentFromDiscord(), desiredFromLocal())).toBe(true);
});
test("ignores Discord null localization maps when local command omits them", () => {
const current = currentFromDiscord({
name_localizations: null,
description_localizations: null,
options: [
{
type: 3,
name: "name",
name_localizations: null,
description: "Skill name",
description_localizations: null,
} as any,
],
});
const desired = desiredFromLocal({
options: [{ name: "name", description: "Skill name", type: 3 }],
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("treats `required: false` on an option as equivalent to field absent", () => {
const current = currentFromDiscord({
name: "skill",
description: "Run a skill.",
options: [{ type: 3, name: "name", description: "Skill name" } as any],
});
const desired = desiredFromLocal({
name: "skill",
description: "Run a skill.",
options: [{ name: "name", description: "Skill name", type: 3, required: false }],
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("keeps `required: true` meaningful", () => {
const current = currentFromDiscord({
name: "skill",
description: "Run a skill.",
options: [{ type: 3, name: "name", description: "Skill name" } as any],
});
const desired = desiredFromLocal({
name: "skill",
description: "Run a skill.",
options: [{ name: "name", description: "Skill name", type: 3, required: true }],
});
expect(commandsEqual(current, desired)).toBe(false);
});
test("treats CJK descriptions with `\\n` separators as equal to Discord's collapsed form", () => {
// Discord server collapses whitespace between CJK characters when storing
// command descriptions, so our local desired `\n`-separated description
// round-trips back without the newline.
const current = currentFromDiscord({
description:
"将任意文本转化为杂志质感 HTML 信息卡片,并自动截图保存为图片。支持直接输入 URL。",
});
const desired = desiredFromLocal({
description:
"将任意文本转化为杂志质感 HTML 信息卡片,并自动截图保存为图片。\n支持直接输入 URL。",
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("treats mixed CJK/ASCII descriptions with consecutive whitespace as equal to collapsed form", () => {
const current = currentFromDiscord({
description: "联网操作策略框架。访问需登录站点时触发。",
});
const desired = desiredFromLocal({
description: "联网操作策略框架。\n\n访问需登录站点时触发。",
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("treats localized descriptions with CJK whitespace as equal to Discord's collapsed form", () => {
const current = currentFromDiscord({
description_localizations: {
"zh-CN": "第一行说明。第二行说明。",
},
});
const desired = desiredFromLocal({
description_localizations: {
"zh-CN": "第一行说明。\n第二行说明。",
},
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("treats option localized descriptions with CJK whitespace as equal to Discord's collapsed form", () => {
const current = currentFromDiscord({
name: "skill",
description: "Run a skill.",
options: [
{
type: 3,
name: "name",
description: "Skill name",
description_localizations: { "zh-CN": "技能名称。直接输入。" },
} as any,
],
});
const desired = desiredFromLocal({
name: "skill",
description: "Run a skill.",
options: [
{
name: "name",
description: "Skill name",
description_localizations: { "zh-CN": "技能名称。\n直接输入。" },
type: 3,
},
],
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("keeps localized substantive description differences meaningful", () => {
const current = currentFromDiscord({
description_localizations: {
"zh-CN": "旧说明",
},
});
const desired = desiredFromLocal({
description_localizations: {
"zh-CN": "新说明",
},
});
expect(commandsEqual(current, desired)).toBe(false);
});
test("keeps substantive description differences meaningful", () => {
const current = currentFromDiscord({ description: "old text" });
const desired = desiredFromLocal({ description: "new text" });
expect(commandsEqual(current, desired)).toBe(false);
});
test("treats ASCII `\\n` as whitespace and collapses it to space for comparison", () => {
// For pure ASCII descriptions, `\n` collapses to a single space so
// "ping the bot" == "ping\nthe bot". The contract is: whitespace
// differences (ASCII or CJK-boundary) are never substantive after
// Discord's server normalization.
const current = currentFromDiscord({ description: "ping the bot" });
const desired = desiredFromLocal({ description: "ping\nthe bot" });
expect(commandsEqual(current, desired)).toBe(true);
});
});

View File

@@ -242,6 +242,7 @@ const optionComparisonOmittedFields = new Set([
"integration_types",
"name_localized",
]);
const nullableLocalizationFields = new Set(["description_localizations", "name_localizations"]);
function stableComparableObject(value: unknown, path: string[] = []): unknown {
if (Array.isArray(value)) {
@@ -268,6 +269,9 @@ function stableComparableObject(value: unknown, path: string[] = []): unknown {
if (entry === undefined) {
return false;
}
if (entry === null && nullableLocalizationFields.has(key)) {
return false;
}
if (path.includes("options") && optionComparisonOmittedFields.has(key)) {
return false;
}
@@ -277,14 +281,70 @@ function stableComparableObject(value: unknown, path: string[] = []): unknown {
return true;
})
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, entry]) => [key, stableComparableObject(entry, [...path, key])]),
.map(([key, entry]) => [
key,
shouldNormalizeDescriptionValue(path, key, entry)
? normalizeDescriptionForComparison(entry)
: stableComparableObject(entry, [...path, key]),
]),
);
}
function shouldNormalizeDescriptionValue(
path: string[],
key: string,
entry: unknown,
): entry is string {
return (
typeof entry === "string" &&
(key === "description" || path.at(-1) === "description_localizations")
);
}
/**
* Normalize a Discord command description for equality comparison.
*
* Discord's server-side storage performs two transformations that our local
* desired descriptors do not:
*
* 1. Consecutive whitespace (including `\n`) is collapsed to a single space.
* 2. Whitespace between two CJK (Chinese, Japanese, Korean) characters is
* removed entirely. So a local description `"第一行。\n第二行。"` is stored
* as `"第一行。第二行。"` on Discord and returned without the `\n`.
*
* Without this normalization every startup for any CJK-heavy deployment reads
* back Discord's collapsed form, computes a diff against the local `\n`-form,
* decides the command needs updating, and issues a `PATCH`. Under the global
* per-application rate limit this quickly produces 429 bursts and some
* commands silently fail to register (see the Discord deploy 429 reports).
*
* Applying the same transformation to both sides before comparison makes the
* equality check match Discord's storage semantics and prevents spurious
* reconcile writes on every startup.
*/
function normalizeDescriptionForComparison(description: string): string {
const collapsed = description.replace(/\s+/g, " ");
// Matches whitespace surrounded by CJK code points. Run twice because a
// single `replace` consumes the boundary characters, which can leave
// adjacent matches (e.g. "字 字 字") partially unhandled.
const cjkBoundaryWhitespace =
/([\u3000-\u303F\u4E00-\u9FFF\uFF00-\uFFEF])\s+([\u3000-\u303F\u4E00-\u9FFF\uFF00-\uFFEF])/g;
return collapsed
.replace(cjkBoundaryWhitespace, "$1$2")
.replace(cjkBoundaryWhitespace, "$1$2")
.trim();
}
function commandsEqual(a: unknown, b: unknown) {
return JSON.stringify(comparableCommand(a)) === JSON.stringify(comparableCommand(b));
}
export const __testing = {
commandsEqual,
comparableCommand,
normalizeDescriptionForComparison,
} as const;
function stableCommandSetHash(commands: SerializedCommand[]): string {
const stable = commands
.map((command) => stableComparableObject(command))