diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dabc95a267..b12d6edf5c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/internal/command-deploy.test.ts b/extensions/discord/src/internal/command-deploy.test.ts new file mode 100644 index 00000000000..3c90af36365 --- /dev/null +++ b/extensions/discord/src/internal/command-deploy.test.ts @@ -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 { + 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 = {}): Record { + 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); + }); +}); diff --git a/extensions/discord/src/internal/command-deploy.ts b/extensions/discord/src/internal/command-deploy.ts index 48541a82eab..1965d3caa1e 100644 --- a/extensions/discord/src/internal/command-deploy.ts +++ b/extensions/discord/src/internal/command-deploy.ts @@ -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))