mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
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:
@@ -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.
|
||||
|
||||
197
extensions/discord/src/internal/command-deploy.test.ts
Normal file
197
extensions/discord/src/internal/command-deploy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user