mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(skills): apply command description limits per channel (#99593)
* fix(skills): keep command spec descriptions UTF-16 safe at the truncation cut * chore: fix oxlint no-unnecessary-type-assertion in test Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: fix Skill type completeness in test fixture Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(skills): prove UTF-16-safe command truncation * fix(skills): apply description limits per channel --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -580,8 +580,8 @@ describe("createDiscordNativeCommand option wiring", () => {
|
||||
expect(respond).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("truncates Discord command and option descriptions to Discord's limit", () => {
|
||||
const longDescription = "x".repeat(140);
|
||||
it("truncates Discord command and option descriptions on a UTF-16 boundary", () => {
|
||||
const longDescription = `${"x".repeat(99)}😀 trailing`;
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const discordConfig = {} as NonNullable<OpenClawConfig["channels"]>["discord"];
|
||||
const command = createDiscordNativeCommand({
|
||||
@@ -606,14 +606,12 @@ describe("createDiscordNativeCommand option wiring", () => {
|
||||
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(command.description).toBe("x".repeat(99));
|
||||
expect(requireOption(command, "input").description).toBe("x".repeat(99));
|
||||
});
|
||||
|
||||
it("serializes localized command descriptions", () => {
|
||||
const longDescription = "k".repeat(140);
|
||||
it("serializes localized command descriptions on a UTF-16 boundary", () => {
|
||||
const longDescription = `${"k".repeat(99)}😀 trailing`;
|
||||
const command = createDiscordNativeCommand({
|
||||
command: {
|
||||
name: "localized",
|
||||
@@ -634,14 +632,14 @@ describe("createDiscordNativeCommand option wiring", () => {
|
||||
|
||||
expect(command.descriptionLocalizations).toEqual({
|
||||
ko: "현지화된 설명",
|
||||
"en-GB": "k".repeat(100),
|
||||
"en-GB": "k".repeat(99),
|
||||
});
|
||||
expect(command.serialize()).toEqual({
|
||||
name: "localized",
|
||||
description: "Default description",
|
||||
description_localizations: {
|
||||
ko: "현지화된 설명",
|
||||
"en-GB": "k".repeat(100),
|
||||
"en-GB": "k".repeat(99),
|
||||
},
|
||||
type: ApplicationCommandType.ChatInput,
|
||||
integration_types: [0, 1],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/native-command-registry";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { AutocompleteInteraction, CommandOptions } from "../internal/discord.js";
|
||||
|
||||
const log = createSubsystemLogger("discord/native-command");
|
||||
@@ -27,7 +28,7 @@ export function truncateDiscordCommandDescription(params: {
|
||||
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);
|
||||
return truncateUtf16Safe(value, DISCORD_COMMAND_DESCRIPTION_MAX);
|
||||
}
|
||||
|
||||
export function truncateDiscordCommandDescriptionLocalizations(params: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
describe("slash-commands", () => {
|
||||
async function registerSingleStatusCommand(
|
||||
requestImpl: (path: string, init?: RequestInit) => Promise<unknown>,
|
||||
description = "status",
|
||||
) {
|
||||
const client: MattermostClient = {
|
||||
baseUrl: "https://chat.example.com",
|
||||
@@ -30,7 +31,7 @@ describe("slash-commands", () => {
|
||||
commands: [
|
||||
{
|
||||
trigger: "oc_status",
|
||||
description: "status",
|
||||
description,
|
||||
autoComplete: true,
|
||||
},
|
||||
],
|
||||
@@ -162,6 +163,37 @@ describe("slash-commands", () => {
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("truncates command descriptions to Mattermost's UTF-8 byte limit", async () => {
|
||||
const description = `${"x".repeat(127)}😀 trailing`;
|
||||
const request = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path.startsWith("/commands?team_id=")) {
|
||||
return [];
|
||||
}
|
||||
if (path === "/commands" && init?.method === "POST") {
|
||||
const body = JSON.parse(typeof init.body === "string" ? init.body : "{}");
|
||||
expect(body.description).toBe("x".repeat(127));
|
||||
expect(body.auto_complete_desc).toBe("x".repeat(127));
|
||||
expect(Buffer.byteLength(body.description, "utf8")).toBeLessThanOrEqual(128);
|
||||
return {
|
||||
id: "cmd-1",
|
||||
token: "tok-1",
|
||||
team_id: "team-1",
|
||||
creator_id: "bot-user",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "http://gateway/callback",
|
||||
auto_complete: true,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected request path: ${path}`);
|
||||
});
|
||||
|
||||
const result = await registerSingleStatusCommand(request, description);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips foreign command trigger collisions instead of mutating non-owned commands", async () => {
|
||||
const request = vi.fn(async (path: string, init?: { method?: string }) => {
|
||||
if (path.startsWith("/commands?team_id=")) {
|
||||
|
||||
@@ -5,6 +5,26 @@ import type { MattermostClient } from "./client.js";
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MATTERMOST_SLASH_POST_METHOD = "P";
|
||||
const MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES = 128;
|
||||
|
||||
// Mattermost rejects command descriptions above 128 UTF-8 bytes. Keep portable
|
||||
// descriptions intact until this API boundary so other channels retain their text.
|
||||
function truncateMattermostCommandDescription(description: string): string {
|
||||
if (Buffer.byteLength(description, "utf8") <= MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES) {
|
||||
return description;
|
||||
}
|
||||
let bytes = 0;
|
||||
let end = 0;
|
||||
for (const char of description) {
|
||||
const charBytes = Buffer.byteLength(char, "utf8");
|
||||
if (bytes + charBytes > MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES) {
|
||||
break;
|
||||
}
|
||||
bytes += charBytes;
|
||||
end += char.length;
|
||||
}
|
||||
return description.slice(0, end);
|
||||
}
|
||||
|
||||
export type MattermostSlashCommandConfig = {
|
||||
/** Enable native slash commands. "auto" resolves to false for now (opt-in). */
|
||||
@@ -177,7 +197,8 @@ export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
|
||||
originalName: "queue",
|
||||
description: "Adjust active-run queue behavior",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[steer|followup|collect|interrupt] [debounce:2s] [cap:N] [drop:old|new|summarize]",
|
||||
autoCompleteHint:
|
||||
"[steer|followup|collect|interrupt] [debounce:2s] [cap:N] [drop:old|new|summarize]",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -290,6 +311,7 @@ export async function registerSlashCommands(params: {
|
||||
const registered: MattermostRegisteredCommand[] = [];
|
||||
|
||||
for (const spec of commands) {
|
||||
const description = truncateMattermostCommandDescription(spec.description);
|
||||
const existingForTrigger = existingByTrigger.get(spec.trigger) ?? [];
|
||||
const ownedCommands = existingForTrigger.filter(
|
||||
(cmd) => cmd.creator_id?.trim() === normalizedCreatorUserId,
|
||||
@@ -344,9 +366,9 @@ export async function registerSlashCommands(params: {
|
||||
trigger: spec.trigger,
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: callbackUrl,
|
||||
description: spec.description,
|
||||
description,
|
||||
auto_complete: spec.autoComplete,
|
||||
auto_complete_desc: spec.description,
|
||||
auto_complete_desc: description,
|
||||
auto_complete_hint: spec.autoCompleteHint,
|
||||
});
|
||||
registered.push({
|
||||
@@ -383,9 +405,9 @@ export async function registerSlashCommands(params: {
|
||||
trigger: spec.trigger,
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: callbackUrl,
|
||||
description: spec.description,
|
||||
description,
|
||||
auto_complete: spec.autoComplete,
|
||||
auto_complete_desc: spec.description,
|
||||
auto_complete_desc: description,
|
||||
auto_complete_hint: spec.autoCompleteHint,
|
||||
});
|
||||
log?.(`mattermost: registered command /${spec.trigger} (id=${created.id})`);
|
||||
|
||||
Reference in New Issue
Block a user