mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(commands): share native plugin command merging (#120972)
This commit is contained in:
committed by
GitHub
parent
0dbdf994b3
commit
6ee409ca7b
@@ -30,7 +30,7 @@ f1f206b2b9d4283be0aabc13a05008fd8e4121d85eb540d67379159e157e4e76 module/channel
|
||||
40f8e726de245e17cf7bf908bb7d6fb084f4927f5ccc20b0c089f47af1bd7a0e module/channel-dm-policy
|
||||
45fd5148ed2ca6b6fdcf0c244e2b51a2fe4d5b6e4d1c3d9875c0e07a31928b12 module/channel-entry-contract
|
||||
56b185eb99eb4981056befd38cd9511b655923934acb1c22aeec7d9c504bdf96 module/channel-feedback
|
||||
23673efb723395ee091ba21a986c7b295fc794875c509eb04aa43e06c503bca5 module/channel-inbound
|
||||
8cb9b544e252c40900ad056a16937b3487a2c4ee67b235cf33a083df9ae6b5d4 module/channel-inbound
|
||||
780abefd1600c55da219da43c769c873d071ab616b5e02d22f365677ed5a7be4 module/channel-inbound-debounce
|
||||
b72485da4fe5d7f73352f4474d00e30b749b64c8a2066755e104958cd96036c2 module/channel-ingress-runtime
|
||||
f83762680c0d0aa6fa1bad9e53c70bfdf3694126dbdd9491db764a69ed3f6d67 module/channel-lifecycle
|
||||
@@ -51,10 +51,10 @@ e787a315cec9681a49a9e5d1af8fe4fae345561d30996888e5ef6593faae0a75 module/channel
|
||||
67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config
|
||||
fdeffe356c7c4edeec9f8fd03edcadc375eabc7a9412e582b10c3180e3ef40fc module/cli-argv
|
||||
ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime
|
||||
575158115f2a8528a61e5765781534dda5eee8729b61dd409819dcabd68c1ab4 module/command-auth
|
||||
b908f15287dc66d3e28a8092eeeffe4bfd3ce2956acabf660c306b213e7185fb module/command-auth-native
|
||||
f3a698172fca42c4effadf11ebf372b1b7d9b3c598ec9967a47db0cbe9faa989 module/command-auth
|
||||
42c8abf6c5579cb751e803781ddf9e1a6b73d26769e0fb88bd3bad54b02d67e4 module/command-auth-native
|
||||
396f60be14d8fe1144076cffeafc2e2ddc0379b5e664f9fd45d13a0edc4a9f2f module/command-detection
|
||||
5155909167a810ca258ec9baf4814e7d495cf25bd287fbb22be55eb1a8d88dfe module/command-primitives-runtime
|
||||
841389ea61d1389d1dbcc9350832e5cb5307ab5b380cbc14800ea62796e06ed7 module/command-primitives-runtime
|
||||
0ab02df5c3904386bfb062e7dce0a24e2ed835b54dcc0112e367346624cb0efe module/command-status
|
||||
02011f70a5de5f1861609206038b101d3e9bbf39bf45a3cfaaae56596299225b module/config-contracts
|
||||
7ee5bc35d30165be33ab229a96dd6a6e3e04c620fa4c41753d568798c514feb3 module/config-mutation
|
||||
@@ -96,7 +96,7 @@ e5d2b540090c17602a1c36b42559f7b516e20730e06bbdd8606597c66ef2ef95 module/memory-
|
||||
577d82ef09e96de6af83a22c9b89cc1ddf2d76964abac5808b102ce04ced294f module/model-session-runtime
|
||||
e775d34923ab24ed4be8bf60b115e6869a9cd5bfca4d8eb9324c5b67209edce9 module/models-provider-runtime
|
||||
b518b4caa7f138d448fe08f5fc9e5f67da5d4b26aafaee2370feb2300e1baa21 module/native-command-config-runtime
|
||||
1e8b5e7bd8234919d339e750368cbe6425e7463d7e63eb5200d728cc17d6e959 module/native-command-registry
|
||||
7a5b56c37e5d862feab8258eb9f79f44e6946333c93b0c22dd3f792b33a7c19a module/native-command-registry
|
||||
15d1d490e4f7909d2d563b83988258a9cc5fa1344cffb0cd3dcd7592c0467632 module/param-readers
|
||||
ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe
|
||||
f8b250da3eab2f85b2bf358872f910b29e541ea04358498a6deab353fa0520fc module/plugin-config-runtime
|
||||
@@ -110,7 +110,7 @@ f319c52a0f1655de752efa55a983893e56e4afa0727f6cc4896c3238c984e91c module/reply-c
|
||||
43562c8fd0c976227051da084698c9aab757a769685b9630cbda8b8cc4cdbb16 module/reply-dispatch-runtime
|
||||
73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history
|
||||
ecc6e3190892ff416ce69d26c8ce0aa08a468c8689ca3606c18b0271e2d87597 module/reply-payload
|
||||
2622e09ff245d5813d305ccb2aadfe485d503484172da9a903508bc4edb344c7 module/reply-runtime
|
||||
1dec24891dabc4d1556aa184add3754389f2233683041db3dbcefb7b11a40eca module/reply-runtime
|
||||
aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk
|
||||
292405cda407e2569f731212f6f981b12bfe682836d5f6e58c6ae194ba27f144 module/routing
|
||||
7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { listNativeCommandSpecsForConfig as listRealNativeCommandSpecsForConfig } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry";
|
||||
import {
|
||||
createTestRegistry,
|
||||
resetPluginRuntimeStateForTest,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { danger, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { discordSetupPlugin } from "../channel.setup.js";
|
||||
import { DISCORD_VOICE_COMMAND_SPEC } from "../voice/command.js";
|
||||
import { resolveDiscordProviderCommandSpecs } from "./provider.commands.js";
|
||||
|
||||
type ResolverParams = Parameters<typeof resolveDiscordProviderCommandSpecs>[0];
|
||||
type SkillCommands = ReturnType<NonNullable<ResolverParams["listSkillCommandsForAgents"]>>;
|
||||
|
||||
const cfg: OpenClawConfig = {};
|
||||
const skillCommands = [
|
||||
{ name: "skill-only", skillName: "Skill Only", description: "Skill only" },
|
||||
{ name: "extra-skill", skillName: "Extra Skill", description: "Extra skill" },
|
||||
];
|
||||
|
||||
function createResolverHarness(
|
||||
options: {
|
||||
pluginCommandSpecs?: NativeCommandSpec[];
|
||||
voiceEnabled?: boolean;
|
||||
nativeCommandSpecs?: NativeCommandSpec[];
|
||||
skillCommands?: SkillCommands;
|
||||
maxDiscordCommands?: number;
|
||||
nativeSkillsEnabled?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const error = vi.fn();
|
||||
const log = vi.fn();
|
||||
const runtime: RuntimeEnv = { error, log, exit: vi.fn() };
|
||||
const configuredSkillCommands = options.skillCommands ?? skillCommands;
|
||||
const nativeCommandSpecs = options.nativeCommandSpecs ?? [
|
||||
{ name: "built-in", description: "Built in", acceptsArgs: false },
|
||||
];
|
||||
const listSkillCommandsForAgents = vi.fn(() => configuredSkillCommands);
|
||||
const listNativeCommandSpecsForConfig = vi.fn(
|
||||
(
|
||||
_config: OpenClawConfig,
|
||||
listOptions?: Parameters<NonNullable<ResolverParams["listNativeCommandSpecsForConfig"]>>[1],
|
||||
): NativeCommandSpec[] => [
|
||||
...nativeCommandSpecs,
|
||||
...(listOptions?.skillCommands ?? []).map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
acceptsArgs: true,
|
||||
})),
|
||||
],
|
||||
);
|
||||
const getPluginCommandSpecs = vi.fn(() => options.pluginCommandSpecs ?? []);
|
||||
|
||||
return {
|
||||
error,
|
||||
getPluginCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
listSkillCommandsForAgents,
|
||||
log,
|
||||
resolve: () =>
|
||||
resolveDiscordProviderCommandSpecs({
|
||||
cfg,
|
||||
runtime,
|
||||
nativeEnabled: true,
|
||||
nativeSkillsEnabled: options.nativeSkillsEnabled ?? true,
|
||||
voiceEnabled: options.voiceEnabled ?? false,
|
||||
maxDiscordCommands: options.maxDiscordCommands ?? 3,
|
||||
listSkillCommandsForAgents,
|
||||
listNativeCommandSpecsForConfig,
|
||||
getPluginCommandSpecs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDiscordProviderCommandSpecs", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
|
||||
it("discards provisional skill collisions when command overflow removes skills", async () => {
|
||||
const harness = createResolverHarness({
|
||||
voiceEnabled: true,
|
||||
maxDiscordCommands: 4,
|
||||
pluginCommandSpecs: [
|
||||
{
|
||||
name: "skill-only",
|
||||
description: "Plugin skill alias",
|
||||
descriptionLocalizations: { de: "Plugin-Fertigkeitsalias" },
|
||||
acceptsArgs: false,
|
||||
},
|
||||
{ name: "plugin-unique", description: "Unique plugin", acceptsArgs: false },
|
||||
],
|
||||
});
|
||||
|
||||
const resolved = await harness.resolve();
|
||||
|
||||
expect(resolved.skillCommands).toEqual([]);
|
||||
expect(resolved.commandSpecs.map((command) => command.name)).toEqual([
|
||||
"built-in",
|
||||
"vc",
|
||||
"skill-only",
|
||||
"plugin-unique",
|
||||
]);
|
||||
expect(resolved.commandSpecs[2]).toEqual({
|
||||
name: "skill-only",
|
||||
description: "Plugin skill alias",
|
||||
descriptionLocalizations: { de: "Plugin-Fertigkeitsalias" },
|
||||
acceptsArgs: false,
|
||||
});
|
||||
expect(harness.error).not.toHaveBeenCalled();
|
||||
expect(harness.getPluginCommandSpecs).toHaveBeenCalledOnce();
|
||||
expect(harness.getPluginCommandSpecs).toHaveBeenCalledWith("discord", { config: cfg });
|
||||
expect(harness.listNativeCommandSpecsForConfig).toHaveBeenCalledTimes(2);
|
||||
expect(harness.log).toHaveBeenCalledOnce();
|
||||
expect(harness.log).toHaveBeenCalledWith(
|
||||
warn(
|
||||
"5 commands exceed the 4-command Discord limit; removing per-skill commands and keeping /skill.",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs a final built-in collision once when command overflow retries without skills", async () => {
|
||||
const harness = createResolverHarness({
|
||||
voiceEnabled: true,
|
||||
maxDiscordCommands: 4,
|
||||
pluginCommandSpecs: [
|
||||
{ name: "built-in", description: "Built-in collision", acceptsArgs: false },
|
||||
{ name: "plugin-unique", description: "Unique plugin", acceptsArgs: false },
|
||||
],
|
||||
});
|
||||
|
||||
const resolved = await harness.resolve();
|
||||
|
||||
expect(resolved.skillCommands).toEqual([]);
|
||||
expect(resolved.commandSpecs.map((command) => command.name)).toEqual([
|
||||
"built-in",
|
||||
"vc",
|
||||
"plugin-unique",
|
||||
]);
|
||||
expect(harness.error).toHaveBeenCalledOnce();
|
||||
expect(harness.error).toHaveBeenCalledWith(
|
||||
danger(
|
||||
'discord: plugin command "/built-in" duplicates an existing native command. Skipping.',
|
||||
),
|
||||
);
|
||||
expect(harness.getPluginCommandSpecs).toHaveBeenCalledOnce();
|
||||
expect(harness.getPluginCommandSpecs).toHaveBeenCalledWith("discord", { config: cfg });
|
||||
expect(harness.listNativeCommandSpecsForConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("counts voice in the exact Discord command limit", async () => {
|
||||
const harness = createResolverHarness({
|
||||
voiceEnabled: true,
|
||||
nativeSkillsEnabled: false,
|
||||
maxDiscordCommands: 100,
|
||||
nativeCommandSpecs: Array.from({ length: 99 }, (_value, index) => ({
|
||||
name: `command-${String(index + 1)}`,
|
||||
description: `Command ${String(index + 1)}`,
|
||||
acceptsArgs: false,
|
||||
})),
|
||||
});
|
||||
|
||||
const resolved = await harness.resolve();
|
||||
|
||||
expect(resolved.commandSpecs).toHaveLength(100);
|
||||
expect(resolved.commandSpecs.at(-1)).toBe(DISCORD_VOICE_COMMAND_SPEC);
|
||||
expect(harness.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains voice ownership when a plugin claims vc", async () => {
|
||||
const harness = createResolverHarness({
|
||||
voiceEnabled: true,
|
||||
nativeSkillsEnabled: false,
|
||||
maxDiscordCommands: 100,
|
||||
pluginCommandSpecs: [{ name: "vc", description: "Plugin voice", acceptsArgs: false }],
|
||||
});
|
||||
|
||||
const resolved = await harness.resolve();
|
||||
|
||||
expect(resolved.commandSpecs.map((command) => command.name)).toEqual(["built-in", "vc"]);
|
||||
expect(resolved.commandSpecs[1]).toBe(DISCORD_VOICE_COMMAND_SPEC);
|
||||
expect(harness.error).toHaveBeenCalledOnce();
|
||||
expect(harness.error).toHaveBeenCalledWith(
|
||||
danger('discord: plugin command "/vc" duplicates an existing native command. Skipping.'),
|
||||
);
|
||||
expect(harness.getPluginCommandSpecs).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a skill named vc from shadowing or duplicating voice", async () => {
|
||||
const vcSkillCommands: SkillCommands = [
|
||||
{ name: "vc", skillName: "Voice Skill", description: "Voice skill" },
|
||||
];
|
||||
const harness = createResolverHarness({
|
||||
voiceEnabled: true,
|
||||
maxDiscordCommands: 100,
|
||||
skillCommands: vcSkillCommands,
|
||||
});
|
||||
|
||||
const resolved = await harness.resolve();
|
||||
|
||||
expect(resolved.skillCommands).toEqual(vcSkillCommands);
|
||||
expect(resolved.commandSpecs.map((command) => command.name)).toEqual(["built-in", "vc"]);
|
||||
expect(resolved.commandSpecs[1]).toBe(DISCORD_VOICE_COMMAND_SPEC);
|
||||
expect(harness.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplicates provider-renamed primary specs before Discord cap planning", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "discord", plugin: discordSetupPlugin, source: "test" }]),
|
||||
);
|
||||
const voiceSkill: SkillCommands[number] = {
|
||||
name: "voice",
|
||||
skillName: "Voice Skill",
|
||||
description: "Skill voice",
|
||||
};
|
||||
const config: OpenClawConfig = { commands: { native: true, nativeSkills: true } };
|
||||
const rawPrimary = listRealNativeCommandSpecsForConfig(config, {
|
||||
provider: "discord",
|
||||
skillCommands: [voiceSkill],
|
||||
});
|
||||
const rawVoice = rawPrimary.filter(
|
||||
(spec) => normalizeLowercaseStringOrEmpty(spec.name) === "voice",
|
||||
);
|
||||
const uniqueCount = new Set(
|
||||
rawPrimary.map((spec) => normalizeLowercaseStringOrEmpty(spec.name)).filter(Boolean),
|
||||
).size;
|
||||
expect(rawVoice).toHaveLength(2);
|
||||
expect(rawVoice.map((spec) => spec.description)).toEqual([
|
||||
"Control text-to-speech (TTS).",
|
||||
"Skill voice",
|
||||
]);
|
||||
const log = vi.fn();
|
||||
|
||||
const resolved = await resolveDiscordProviderCommandSpecs({
|
||||
cfg: config,
|
||||
runtime: { log, error: vi.fn(), exit: vi.fn() },
|
||||
nativeEnabled: true,
|
||||
nativeSkillsEnabled: true,
|
||||
voiceEnabled: false,
|
||||
maxDiscordCommands: uniqueCount,
|
||||
listSkillCommandsForAgents: vi.fn(() => [voiceSkill]),
|
||||
getPluginCommandSpecs: vi.fn(() => []),
|
||||
});
|
||||
|
||||
expect(resolved.skillCommands).toEqual([voiceSkill]);
|
||||
expect(resolved.commandSpecs).toHaveLength(uniqueCount);
|
||||
expect(
|
||||
resolved.commandSpecs.filter(
|
||||
(spec) => normalizeLowercaseStringOrEmpty(spec.name) === "voice",
|
||||
),
|
||||
).toEqual([expect.objectContaining({ description: "Control text-to-speech (TTS)." })]);
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,16 @@
|
||||
import {
|
||||
listNativeCommandSpecsForConfig,
|
||||
listSkillCommandsForAgents,
|
||||
type NativeCommandSpec,
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { danger, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeStringEntriesLower,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
mergeNativeCommandSpecs,
|
||||
type NativeCommandSpec,
|
||||
} from "openclaw/plugin-sdk/native-command-registry";
|
||||
import { danger, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { DISCORD_VOICE_COMMAND_SPEC } from "../voice/command.js";
|
||||
|
||||
export type GetPluginCommandSpecs =
|
||||
typeof import("openclaw/plugin-sdk/plugin-runtime").getPluginCommandSpecs;
|
||||
@@ -20,43 +21,12 @@ const loadPluginCommandSpecs = createLazyRuntimeNamedExport(
|
||||
"getPluginCommandSpecs",
|
||||
);
|
||||
|
||||
async function appendPluginCommandSpecs(params: {
|
||||
commandSpecs: NativeCommandSpec[];
|
||||
runtime: RuntimeEnv;
|
||||
cfg: OpenClawConfig;
|
||||
getPluginCommandSpecs?: GetPluginCommandSpecs;
|
||||
}): Promise<NativeCommandSpec[]> {
|
||||
const merged = [...params.commandSpecs];
|
||||
const existingNames = new Set(normalizeStringEntriesLower(merged.map((spec) => spec.name)));
|
||||
const getPluginCommandSpecs = params.getPluginCommandSpecs ?? (await loadPluginCommandSpecs());
|
||||
for (const pluginCommand of getPluginCommandSpecs("discord", { config: params.cfg })) {
|
||||
const normalizedName = normalizeLowercaseStringOrEmpty(pluginCommand.name);
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
if (existingNames.has(normalizedName)) {
|
||||
params.runtime.error?.(
|
||||
danger(
|
||||
`discord: plugin command "/${normalizedName}" duplicates an existing native command. Skipping.`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
existingNames.add(normalizedName);
|
||||
merged.push({
|
||||
name: pluginCommand.name,
|
||||
description: pluginCommand.description,
|
||||
acceptsArgs: pluginCommand.acceptsArgs,
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function resolveDiscordProviderCommandSpecs(params: {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
nativeEnabled: boolean;
|
||||
nativeSkillsEnabled: boolean;
|
||||
voiceEnabled: boolean;
|
||||
maxDiscordCommands?: number;
|
||||
listSkillCommandsForAgents?: typeof listSkillCommandsForAgents;
|
||||
listNativeCommandSpecsForConfig?: typeof listNativeCommandSpecsForConfig;
|
||||
@@ -69,24 +39,57 @@ export async function resolveDiscordProviderCommandSpecs(params: {
|
||||
const listNativeCommandSpecs =
|
||||
params.listNativeCommandSpecsForConfig ?? listNativeCommandSpecsForConfig;
|
||||
const maxDiscordCommands = params.maxDiscordCommands ?? 100;
|
||||
const pluginCommandSpecs = params.nativeEnabled
|
||||
? (params.getPluginCommandSpecs ?? (await loadPluginCommandSpecs()))("discord", {
|
||||
config: params.cfg,
|
||||
})
|
||||
: [];
|
||||
const onCollision = (normalizedName: string) => {
|
||||
params.runtime.error?.(
|
||||
danger(
|
||||
`discord: plugin command "/${normalizedName}" duplicates an existing native command. Skipping.`,
|
||||
),
|
||||
);
|
||||
};
|
||||
const mergePluginCommandSpecs = (
|
||||
primary: readonly NativeCommandSpec[],
|
||||
collisionHandler: (normalizedName: string) => void,
|
||||
) =>
|
||||
mergeNativeCommandSpecs({
|
||||
primary,
|
||||
secondary: pluginCommandSpecs,
|
||||
onCollision: collisionHandler,
|
||||
});
|
||||
const listPrimaryCommandSpecs = (
|
||||
skillCommands: ReturnType<typeof listSkillCommandsForAgents>,
|
||||
): NativeCommandSpec[] => {
|
||||
const standardSpecs = listNativeCommandSpecs(params.cfg, {
|
||||
skillCommands,
|
||||
provider: "discord",
|
||||
});
|
||||
if (!params.voiceEnabled) {
|
||||
return standardSpecs;
|
||||
}
|
||||
// The specialized voice handler owns /vc before plugin merging and cap planning;
|
||||
// remove generic specs so the deployed catalog cannot contain a shadow duplicate.
|
||||
const voiceName = DISCORD_VOICE_COMMAND_SPEC.name;
|
||||
return [
|
||||
...standardSpecs.filter((spec) => normalizeLowercaseStringOrEmpty(spec.name) !== voiceName),
|
||||
DISCORD_VOICE_COMMAND_SPEC,
|
||||
];
|
||||
};
|
||||
// Defer first-pass diagnostics until the skill-limit decision. A collision can disappear
|
||||
// when fallback removes skills, so only the final retained command set should report it.
|
||||
const provisionalCollisions: string[] = [];
|
||||
let skillCommands =
|
||||
params.nativeEnabled && params.nativeSkillsEnabled
|
||||
? listSkillCommands({ cfg: params.cfg })
|
||||
: [];
|
||||
let commandSpecs = params.nativeEnabled
|
||||
? listNativeCommandSpecs(params.cfg, {
|
||||
skillCommands,
|
||||
provider: "discord",
|
||||
})
|
||||
? mergePluginCommandSpecs(listPrimaryCommandSpecs(skillCommands), (normalizedName) =>
|
||||
provisionalCollisions.push(normalizedName),
|
||||
)
|
||||
: [];
|
||||
if (params.nativeEnabled) {
|
||||
commandSpecs = await appendPluginCommandSpecs({
|
||||
commandSpecs,
|
||||
runtime: params.runtime,
|
||||
cfg: params.cfg,
|
||||
getPluginCommandSpecs: params.getPluginCommandSpecs,
|
||||
});
|
||||
}
|
||||
const initialCommandCount = commandSpecs.length;
|
||||
if (
|
||||
params.nativeEnabled &&
|
||||
@@ -94,21 +97,16 @@ export async function resolveDiscordProviderCommandSpecs(params: {
|
||||
commandSpecs.length > maxDiscordCommands
|
||||
) {
|
||||
skillCommands = [];
|
||||
commandSpecs = listNativeCommandSpecs(params.cfg, {
|
||||
skillCommands: [],
|
||||
provider: "discord",
|
||||
});
|
||||
commandSpecs = await appendPluginCommandSpecs({
|
||||
commandSpecs,
|
||||
runtime: params.runtime,
|
||||
cfg: params.cfg,
|
||||
getPluginCommandSpecs: params.getPluginCommandSpecs,
|
||||
});
|
||||
commandSpecs = mergePluginCommandSpecs(listPrimaryCommandSpecs([]), onCollision);
|
||||
params.runtime.log?.(
|
||||
warn(
|
||||
`${initialCommandCount} commands exceed the ${maxDiscordCommands}-command Discord limit; removing per-skill commands and keeping /skill.`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
for (const normalizedName of provisionalCollisions) {
|
||||
onCollision(normalizedName);
|
||||
}
|
||||
}
|
||||
if (params.nativeEnabled && commandSpecs.length > maxDiscordCommands) {
|
||||
params.runtime.log?.(
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DISCORD_VOICE_COMMAND_SPEC } from "../voice/command.js";
|
||||
import { createDiscordProviderInteractionSurface } from "./provider.interactions.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.manager.js";
|
||||
|
||||
type InteractionParams = Parameters<typeof createDiscordProviderInteractionSurface>[0];
|
||||
type CreateNativeCommand = NonNullable<InteractionParams["createNativeCommand"]>;
|
||||
|
||||
const normalCommandSpec: NativeCommandSpec = {
|
||||
name: "normal",
|
||||
description: "Normal command",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
|
||||
function createInteractionHarness(params: {
|
||||
commandSpecs: NativeCommandSpec[];
|
||||
voiceEnabled: boolean;
|
||||
}) {
|
||||
const createNativeCommand = vi.fn(
|
||||
(options: Parameters<CreateNativeCommand>[0]): ReturnType<CreateNativeCommand> =>
|
||||
({ name: options.command.name }) as ReturnType<CreateNativeCommand>,
|
||||
);
|
||||
const surface = createDiscordProviderInteractionSurface({
|
||||
cfg: {} as OpenClawConfig,
|
||||
discordConfig: {
|
||||
agentComponents: { enabled: false },
|
||||
execApprovals: { enabled: false },
|
||||
} as DiscordAccountConfig,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
commandSpecs: params.commandSpecs,
|
||||
nativeEnabled: true,
|
||||
voiceEnabled: params.voiceEnabled,
|
||||
groupPolicy: "open",
|
||||
useAccessGroups: false,
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
voiceManagerRef: { current: null },
|
||||
guildEntries: undefined,
|
||||
allowFrom: [],
|
||||
dmPolicy: "open",
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } satisfies RuntimeEnv,
|
||||
createNativeCommand,
|
||||
});
|
||||
return { createNativeCommand, surface };
|
||||
}
|
||||
|
||||
describe("createDiscordProviderInteractionSurface", () => {
|
||||
it("constructs the resolver-owned vc spec as the specialized voice command", () => {
|
||||
const { createNativeCommand, surface } = createInteractionHarness({
|
||||
commandSpecs: [normalCommandSpec, DISCORD_VOICE_COMMAND_SPEC],
|
||||
voiceEnabled: true,
|
||||
});
|
||||
|
||||
expect(createNativeCommand).toHaveBeenCalledOnce();
|
||||
expect(createNativeCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: normalCommandSpec }),
|
||||
);
|
||||
expect(surface.commands.map((command) => command.name)).toEqual(["normal", "vc"]);
|
||||
const voiceCommand = surface.commands[1];
|
||||
expect(voiceCommand?.serialize().options?.map((option) => option.name)).toEqual([
|
||||
"join",
|
||||
"leave",
|
||||
"status",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not append a hidden voice command when voice is disabled", () => {
|
||||
const { createNativeCommand, surface } = createInteractionHarness({
|
||||
commandSpecs: [normalCommandSpec],
|
||||
voiceEnabled: false,
|
||||
});
|
||||
|
||||
expect(createNativeCommand).toHaveBeenCalledOnce();
|
||||
expect(surface.commands.map((command) => command.name)).toEqual(["normal"]);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@
|
||||
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
|
||||
import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createDiscordActivityButton } from "../activities/interaction.js";
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isDiscordExecApprovalClientEnabled,
|
||||
} from "../exec-approvals.js";
|
||||
import type { BaseCommand, BaseMessageInteractiveComponent, Modal } from "../internal/discord.js";
|
||||
import { createDiscordVoiceCommand } from "../voice/command.js";
|
||||
import { createDiscordVoiceCommand, DISCORD_VOICE_COMMAND_SPEC } from "../voice/command.js";
|
||||
import {
|
||||
createAgentComponentControls,
|
||||
createDiscordComponentControls,
|
||||
@@ -60,20 +60,13 @@ export function createDiscordProviderInteractionSurface(params: {
|
||||
modals: Modal[];
|
||||
} {
|
||||
const createNativeCommand = params.createNativeCommand ?? createDiscordNativeCommand;
|
||||
const commands: BaseCommand[] = params.commandSpecs.map((spec) =>
|
||||
createNativeCommand({
|
||||
command: spec,
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: params.accountId,
|
||||
sessionPrefix: params.sessionPrefix,
|
||||
ephemeralDefault: params.ephemeralDefault,
|
||||
threadBindings: params.threadBindings,
|
||||
}),
|
||||
);
|
||||
if (params.nativeEnabled && params.voiceEnabled) {
|
||||
commands.push(
|
||||
createDiscordVoiceCommand({
|
||||
const commands: BaseCommand[] = params.commandSpecs.map((spec) => {
|
||||
if (
|
||||
params.nativeEnabled &&
|
||||
params.voiceEnabled &&
|
||||
spec.name === DISCORD_VOICE_COMMAND_SPEC.name
|
||||
) {
|
||||
return createDiscordVoiceCommand({
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: params.accountId,
|
||||
@@ -81,9 +74,18 @@ export function createDiscordProviderInteractionSurface(params: {
|
||||
useAccessGroups: params.useAccessGroups,
|
||||
getManager: () => params.voiceManagerRef.current,
|
||||
ephemeralDefault: params.ephemeralDefault,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return createNativeCommand({
|
||||
command: spec,
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: params.accountId,
|
||||
sessionPrefix: params.sessionPrefix,
|
||||
ephemeralDefault: params.ephemeralDefault,
|
||||
threadBindings: params.threadBindings,
|
||||
});
|
||||
});
|
||||
|
||||
const execApprovalsConfig = params.discordConfig.execApprovals ?? {};
|
||||
const execApprovalsEnabled = isDiscordExecApprovalClientEnabled({
|
||||
|
||||
@@ -249,6 +249,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
runtime,
|
||||
nativeEnabled,
|
||||
nativeSkillsEnabled,
|
||||
voiceEnabled,
|
||||
listSkillCommandsForAgents: discordProviderRuntime.listSkillCommandsForAgents,
|
||||
listNativeCommandSpecsForConfig: discordProviderRuntime.listNativeCommandSpecsForConfig,
|
||||
getPluginCommandSpecs: discordProviderRuntime.getPluginCommandSpecs,
|
||||
|
||||
@@ -471,7 +471,12 @@ vi.mock(buildDiscordSourceModuleId("token.js"), () => ({
|
||||
}));
|
||||
|
||||
vi.mock(buildDiscordSourceModuleId("voice/command.js"), () => ({
|
||||
createDiscordVoiceCommand: () => ({ name: "voice-command" }),
|
||||
DISCORD_VOICE_COMMAND_SPEC: {
|
||||
name: "vc",
|
||||
description: "Voice channel controls",
|
||||
acceptsArgs: false,
|
||||
},
|
||||
createDiscordVoiceCommand: () => ({ name: "vc" }),
|
||||
}));
|
||||
|
||||
vi.mock(buildDiscordSourceModuleId("monitor/agent-components.js"), () => ({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "discord-api-types/v10";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry";
|
||||
import {
|
||||
Command,
|
||||
CommandWithSubcommands,
|
||||
@@ -25,6 +26,12 @@ const VOICE_CHANNEL_TYPES: NonNullable<APIApplicationCommandChannelOption["chann
|
||||
DiscordChannelType.GuildStageVoice,
|
||||
];
|
||||
|
||||
export const DISCORD_VOICE_COMMAND_SPEC = {
|
||||
name: "vc",
|
||||
description: "Voice channel controls",
|
||||
acceptsArgs: false,
|
||||
} satisfies NativeCommandSpec;
|
||||
|
||||
type VoiceCommandContext = {
|
||||
cfg: OpenClawConfig;
|
||||
discordConfig: DiscordAccountConfig;
|
||||
@@ -272,8 +279,8 @@ export function createDiscordVoiceCommand(params: VoiceCommandContext): CommandW
|
||||
}
|
||||
|
||||
return new (class extends CommandWithSubcommands {
|
||||
override name = "vc";
|
||||
override description = "Voice channel controls";
|
||||
override name = DISCORD_VOICE_COMMAND_SPEC.name;
|
||||
override description = DISCORD_VOICE_COMMAND_SPEC.description;
|
||||
subcommands = [new JoinCommand(), new LeaveCommand(), new StatusCommand()];
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type {
|
||||
ChatCommandDefinition,
|
||||
NativeCommandSpec,
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { ChatCommandDefinition } from "openclaw/plugin-sdk/command-auth-native";
|
||||
// Slack tests cover slash plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
|
||||
import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
@@ -118,6 +116,14 @@ const slashCommandFixtures = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const pluginCommandFixtures = vi.hoisted(() => ({
|
||||
specs: [] as NativeCommandSpec[],
|
||||
}));
|
||||
|
||||
const skillCommandFixtures = vi.hoisted(() => ({
|
||||
commands: [] as Array<{ name: string; skillName: string; description: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("./slash-commands.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./slash-commands.runtime.js")>(
|
||||
"./slash-commands.runtime.js",
|
||||
@@ -146,6 +152,26 @@ vi.mock("./slash-commands.runtime.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./slash-plugin-commands.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./slash-plugin-commands.runtime.js")>(
|
||||
"./slash-plugin-commands.runtime.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listProviderPluginCommandSpecs: () => pluginCommandFixtures.specs,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./slash-skill-commands.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./slash-skill-commands.runtime.js")>(
|
||||
"./slash-skill-commands.runtime.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listSkillCommandsForAgents: () => skillCommandFixtures.commands,
|
||||
};
|
||||
});
|
||||
|
||||
type RegisterFn = (params: {
|
||||
ctx: unknown;
|
||||
account: unknown;
|
||||
@@ -157,11 +183,15 @@ const { registerSlackMonitorSlashCommands } = (await import("./slash.js")) as {
|
||||
const { dispatchMock } = getSlackSlashMocks();
|
||||
|
||||
beforeEach(() => {
|
||||
pluginCommandFixtures.specs = [];
|
||||
skillCommandFixtures.commands = [];
|
||||
clearRuntimeConfigSnapshot();
|
||||
resetSlackSlashMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pluginCommandFixtures.specs = [];
|
||||
skillCommandFixtures.commands = [];
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
@@ -193,6 +223,7 @@ function createArgMenusHarness(
|
||||
cfg: OpenClawConfig = { commands: { native: true, nativeSkills: false } },
|
||||
) {
|
||||
const commands = new Map<string | RegExp, (args: unknown) => Promise<void>>();
|
||||
const commandRegistrations: Array<string | RegExp> = [];
|
||||
const actions = new Map<string | RegExp, (args: unknown) => Promise<void>>();
|
||||
const options = new Map<string, (args: unknown) => Promise<void>>();
|
||||
const optionsReceiverContexts: unknown[] = [];
|
||||
@@ -201,6 +232,7 @@ function createArgMenusHarness(
|
||||
const app = {
|
||||
client: { chat: { postEphemeral } },
|
||||
command: (name: string | RegExp, handler: (args: unknown) => Promise<void>) => {
|
||||
commandRegistrations.push(name);
|
||||
commands.set(name, handler);
|
||||
},
|
||||
action: (id: string | RegExp, handler: (args: unknown) => Promise<void>) => {
|
||||
@@ -246,6 +278,7 @@ function createArgMenusHarness(
|
||||
} as unknown;
|
||||
|
||||
return {
|
||||
commandRegistrations,
|
||||
commands,
|
||||
actions,
|
||||
options,
|
||||
@@ -726,6 +759,50 @@ describe("Slack native command argument menus", () => {
|
||||
expect(testHarness.optionsReceiverContexts[0]).toBe(testHarness.app);
|
||||
});
|
||||
|
||||
it("registers unique plugin commands and silently keeps primary names on collision", async () => {
|
||||
pluginCommandFixtures.specs = [
|
||||
{ name: "slackplugin", description: "Unique plugin command", acceptsArgs: false },
|
||||
{ name: "reportlong", description: "Colliding plugin command", acceptsArgs: false },
|
||||
];
|
||||
const testHarness = createArgMenusHarness();
|
||||
const runtimeLog = vi.fn();
|
||||
const runtimeError = vi.fn();
|
||||
(
|
||||
testHarness.ctx as { runtime: { log: typeof runtimeLog; error: typeof runtimeError } }
|
||||
).runtime = { log: runtimeLog, error: runtimeError };
|
||||
|
||||
await registerCommands(testHarness.ctx, testHarness.account);
|
||||
|
||||
expect(testHarness.commands.has("/slackplugin")).toBe(true);
|
||||
expect(testHarness.commandRegistrations.filter((name) => name === "/slackplugin")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(testHarness.commandRegistrations.filter((name) => name === "/reportlong")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(runtimeLog).not.toHaveBeenCalled();
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplicates a skill after the Slack status native rename", async () => {
|
||||
skillCommandFixtures.commands = [
|
||||
{
|
||||
name: "agentstatus",
|
||||
skillName: "Agent Status Skill",
|
||||
description: "Skill agent status",
|
||||
},
|
||||
];
|
||||
const config: OpenClawConfig = { commands: { native: true, nativeSkills: true } };
|
||||
const testHarness = createArgMenusHarness(config);
|
||||
(testHarness.account as { config: OpenClawConfig }).config = config;
|
||||
|
||||
await registerCommands(testHarness.ctx, testHarness.account);
|
||||
|
||||
expect(testHarness.commandRegistrations.filter((name) => name === "/agentstatus")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ agentRuntime: "codex", includesUltra: false },
|
||||
{ agentRuntime: "openclaw", includesUltra: true },
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
resolveNativeCommandsEnabled,
|
||||
resolveNativeSkillsEnabled,
|
||||
} from "openclaw/plugin-sdk/native-command-config-runtime";
|
||||
import {
|
||||
mergeNativeCommandSpecs,
|
||||
type NativeCommandSpec,
|
||||
} from "openclaw/plugin-sdk/native-command-registry";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -30,7 +34,6 @@ import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-s
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntriesLower,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { chunkItems } from "openclaw/plugin-sdk/text-chunking";
|
||||
import type { ResolvedSlackAccount } from "../accounts.js";
|
||||
@@ -877,7 +880,7 @@ export async function registerSlackMonitorSlashCommands(params: {
|
||||
}
|
||||
};
|
||||
|
||||
let nativeCommands: Array<{ name: string }> = [];
|
||||
let nativeCommands: NativeCommandSpec[] = [];
|
||||
let slashCommandsRuntime: typeof import("./slash-commands.runtime.js") | null = null;
|
||||
if (
|
||||
registration.mode === "disabled" &&
|
||||
@@ -899,18 +902,11 @@ export async function registerSlackMonitorSlashCommands(params: {
|
||||
skillCommands,
|
||||
provider: "slack",
|
||||
});
|
||||
const existingNativeNames = new Set(
|
||||
normalizeStringEntriesLower(nativeCommands.map((command) => command.name)),
|
||||
);
|
||||
const { listProviderPluginCommandSpecs } = await loadSlackPluginCommandsRuntime();
|
||||
for (const pluginCommand of listProviderPluginCommandSpecs("slack")) {
|
||||
const normalizedName = normalizeLowercaseStringOrEmpty(pluginCommand.name);
|
||||
if (!normalizedName || existingNativeNames.has(normalizedName)) {
|
||||
continue;
|
||||
}
|
||||
existingNativeNames.add(normalizedName);
|
||||
nativeCommands.push(pluginCommand);
|
||||
}
|
||||
nativeCommands = mergeNativeCommandSpecs({
|
||||
primary: nativeCommands,
|
||||
secondary: listProviderPluginCommandSpecs("slack"),
|
||||
});
|
||||
registration = nativeCommands.length > 0 ? { mode: "native" } : { mode: "disabled" };
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
|
||||
// +3: channel streaming config reader re-exports and session-agent scope resolver.
|
||||
// +3: session-catalog terminal-start provider request and Gateway params/result contracts.
|
||||
// +1: worker desktop endpoint contract for desktop-capable worker leases.
|
||||
4848,
|
||||
// +1: native command spec merger through the native-command-registry facade.
|
||||
4849,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
@@ -303,7 +304,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
|
||||
// +1: simple channel secret contract factory replacing repeated collectors.
|
||||
// +4: focused agent scope functions for doctor migration enumeration.
|
||||
// +3: channel streaming config reader functions and session-agent scope resolver.
|
||||
2920,
|
||||
// +1: native command spec merger through the native-command-registry facade.
|
||||
2921,
|
||||
env,
|
||||
),
|
||||
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
listChatCommandsForConfig,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
mergeNativeCommandSpecs,
|
||||
normalizeCommandBody,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgChoices,
|
||||
@@ -381,6 +382,47 @@ describe("commands registry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("merges native command specs with primary precedence and stable secondary order", () => {
|
||||
const primary: readonly NativeCommandSpec[] = [
|
||||
{ name: " Primary ", description: "primary", acceptsArgs: false },
|
||||
{ name: "", description: "blank primary", acceptsArgs: false },
|
||||
{ name: "PRIMARY", description: "duplicate primary", acceptsArgs: false },
|
||||
];
|
||||
const acceptedSecondary: NativeCommandSpec = {
|
||||
name: "Secondary",
|
||||
description: "secondary",
|
||||
descriptionLocalizations: { de: "Sekundär" },
|
||||
acceptsArgs: true,
|
||||
args: [{ name: "value", description: "value", type: "string" }],
|
||||
isAlias: true,
|
||||
};
|
||||
const secondary: readonly NativeCommandSpec[] = [
|
||||
{ name: "primary", description: "primary collision", acceptsArgs: false },
|
||||
{ name: " ", description: "blank secondary", acceptsArgs: false },
|
||||
acceptedSecondary,
|
||||
{ name: " secondary ", description: "secondary collision", acceptsArgs: false },
|
||||
{ name: "third", description: "third", acceptsArgs: false },
|
||||
];
|
||||
const primaryBefore = structuredClone(primary);
|
||||
const secondaryBefore = structuredClone(secondary);
|
||||
const collisions: string[] = [];
|
||||
|
||||
const merged = mergeNativeCommandSpecs({
|
||||
primary,
|
||||
secondary,
|
||||
onCollision: (name) => collisions.push(name),
|
||||
});
|
||||
|
||||
expect(merged).toEqual([primary[0], acceptedSecondary, secondary[4]]);
|
||||
expect(merged).not.toBe(primary);
|
||||
expect(merged[0]).toBe(primary[0]);
|
||||
expect(merged[1]).toBe(acceptedSecondary);
|
||||
expect(merged[2]).toBe(secondary[4]);
|
||||
expect(collisions).toEqual(["primary", "secondary"]);
|
||||
expect(primary).toEqual(primaryBefore);
|
||||
expect(secondary).toEqual(secondaryBefore);
|
||||
});
|
||||
|
||||
it("applies discord native command overrides", () => {
|
||||
installDiscordNativeCommandOverrides();
|
||||
const native = listNativeCommandSpecsForConfig(
|
||||
|
||||
@@ -163,6 +163,36 @@ export function listNativeCommandSpecsForConfig(
|
||||
return listNativeSpecsFromCommands(listChatCommandsForConfig(cfg, params), params?.provider);
|
||||
}
|
||||
|
||||
export function mergeNativeCommandSpecs(params: {
|
||||
primary: readonly NativeCommandSpec[];
|
||||
secondary: readonly NativeCommandSpec[];
|
||||
onCollision?: (normalizedName: string) => void;
|
||||
}): NativeCommandSpec[] {
|
||||
const merged: NativeCommandSpec[] = [];
|
||||
const names = new Set<string>();
|
||||
const append = (spec: NativeCommandSpec, reportCollision: boolean) => {
|
||||
const normalizedName = normalizeOptionalLowercaseString(spec.name);
|
||||
if (!normalizedName) {
|
||||
return;
|
||||
}
|
||||
if (names.has(normalizedName)) {
|
||||
if (reportCollision) {
|
||||
params.onCollision?.(normalizedName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
names.add(normalizedName);
|
||||
merged.push(spec);
|
||||
};
|
||||
for (const spec of params.primary) {
|
||||
append(spec, false);
|
||||
}
|
||||
for (const spec of params.secondary) {
|
||||
append(spec, true);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Finds a command definition by provider-native command name or native alias. */
|
||||
export function findCommandByNativeName(
|
||||
name: string,
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
listChatCommands,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
mergeNativeCommandSpecs,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgChoices,
|
||||
resolveCommandArgMenu,
|
||||
|
||||
Reference in New Issue
Block a user