refactor: move full validation to ChatInputCommandBuilder (#11304)

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Almeida
2026-01-16 16:54:51 +00:00
committed by GitHub
parent be78e26729
commit 017176926a
9 changed files with 113 additions and 71 deletions

View File

@@ -379,21 +379,9 @@ describe('ChatInput Commands', () => {
describe('Subcommand builder and subcommand group builder', () => {
test('GIVEN both types THEN does not throw error', () => {
expect(() =>
getBuilder()
.setName('test')
.setDescription('Test command')
.addSubcommands((subcommand) =>
subcommand.setName('subcommand').setDescription('Description of subcommand'),
)
.addSubcommandGroups((subcommandGroup) =>
subcommandGroup
.setName('group')
.setDescription('Description of group')
.addSubcommands((subcommand) =>
subcommand.setName('subcommand').setDescription('Description of group subcommand'),
),
)
getNamedBuilder()
.addSubcommands(getSubcommand())
.addSubcommandGroups(getSubcommandGroup().addSubcommands(getSubcommand()))
.toJSON(),
).not.toThrowError();
});

View File

@@ -2,6 +2,7 @@ import {
ApplicationIntegrationType,
InteractionContextType,
ApplicationCommandOptionType,
ApplicationCommandType,
} from 'discord-api-types/v10';
import { z } from 'zod';
import { localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js';
@@ -47,38 +48,55 @@ const choiceBasePredicate = z.object({
name: choiceValueStringPredicate,
name_localizations: localeMapPredicate.optional(),
});
const choiceStringPredicate = choiceBasePredicate.extend({
const choiceStringPredicate = z.object({
...choiceBasePredicate.shape,
value: choiceValueStringPredicate,
});
const choiceNumberPredicate = choiceBasePredicate.extend({
const choiceNumberPredicate = z.object({
...choiceBasePredicate.shape,
value: choiceValueNumberPredicate,
});
const choiceBaseMixinPredicate = z.object({
autocomplete: z.literal(false).optional(),
});
const choiceStringMixinPredicate = choiceBaseMixinPredicate.extend({
const choiceStringMixinPredicate = z.object({
...choiceBaseMixinPredicate.shape,
choices: choiceStringPredicate.array().max(25).optional(),
});
const choiceNumberMixinPredicate = choiceBaseMixinPredicate.extend({
const choiceNumberMixinPredicate = z.object({
...choiceBaseMixinPredicate.shape,
choices: choiceNumberPredicate.array().max(25).optional(),
});
const basicOptionTypesPredicate = z.literal([
ApplicationCommandOptionType.Attachment,
ApplicationCommandOptionType.Boolean,
ApplicationCommandOptionType.Channel,
ApplicationCommandOptionType.Integer,
ApplicationCommandOptionType.Mentionable,
ApplicationCommandOptionType.Number,
ApplicationCommandOptionType.Role,
ApplicationCommandOptionType.String,
ApplicationCommandOptionType.User,
]);
export const basicOptionPredicate = sharedNameAndDescriptionPredicate.extend({
export const baseBasicOptionPredicate = z.object({
...sharedNameAndDescriptionPredicate.shape,
required: z.boolean().optional(),
type: basicOptionTypesPredicate,
});
export const attachmentOptionPredicate = z.object({
...baseBasicOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Attachment),
});
export const booleanOptionPredicate = z.object({
...baseBasicOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Boolean),
});
export const mentionableOptionPredicate = z.object({
...baseBasicOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Mentionable),
});
export const roleOptionPredicate = z.object({
...baseBasicOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Role),
});
export const userOptionPredicate = z.object({
...baseBasicOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.User),
});
const autocompleteOrStringChoicesMixinOptionPredicate = z.discriminatedUnion('autocomplete', [
@@ -92,64 +110,71 @@ const autocompleteOrNumberChoicesMixinOptionPredicate = z.discriminatedUnion('au
]);
export const channelOptionPredicate = z.object({
...basicOptionPredicate.shape,
...baseBasicOptionPredicate.shape,
...channelMixinOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Channel),
});
export const integerOptionPredicate = z
.object({
...basicOptionPredicate.shape,
...baseBasicOptionPredicate.shape,
...numericMixinIntegerOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Integer),
})
.and(autocompleteOrNumberChoicesMixinOptionPredicate);
export const numberOptionPredicate = z
.object({
...basicOptionPredicate.shape,
...baseBasicOptionPredicate.shape,
...numericMixinNumberOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Number),
})
.and(autocompleteOrNumberChoicesMixinOptionPredicate);
export const stringOptionPredicate = basicOptionPredicate
.extend({
export const stringOptionPredicate = z
.object({
...baseBasicOptionPredicate.shape,
max_length: z.number().min(1).max(6_000).optional(),
min_length: z.number().min(0).max(6_000).optional(),
type: z.literal(ApplicationCommandOptionType.String),
})
.and(autocompleteOrStringChoicesMixinOptionPredicate);
const baseChatInputCommandPredicate = sharedNameAndDescriptionPredicate.extend({
const basicOptionPredicates = [
attachmentOptionPredicate,
booleanOptionPredicate,
channelOptionPredicate,
integerOptionPredicate,
mentionableOptionPredicate,
numberOptionPredicate,
roleOptionPredicate,
stringOptionPredicate,
userOptionPredicate,
];
export const chatInputCommandSubcommandPredicate = z.object({
...sharedNameAndDescriptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Subcommand),
options: z.array(z.union(basicOptionPredicates)).max(25).optional(),
});
export const chatInputCommandSubcommandGroupPredicate = z.object({
...sharedNameAndDescriptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.SubcommandGroup),
options: z.array(chatInputCommandSubcommandPredicate).min(1).max(25),
});
export const chatInputCommandPredicate = z.object({
...sharedNameAndDescriptionPredicate.shape,
contexts: z.array(z.enum(InteractionContextType)).optional(),
default_member_permissions: memberPermissionsPredicate.optional(),
integration_types: z.array(z.enum(ApplicationIntegrationType)).optional(),
nsfw: z.boolean().optional(),
});
// Because you can only add options via builders, there's no need to validate whole objects here otherwise
const chatInputCommandOptionsPredicate = z.union([
z.object({ type: basicOptionTypesPredicate }).array(),
z
.object({
type: z.union([
z.literal(ApplicationCommandOptionType.Subcommand),
z.literal(ApplicationCommandOptionType.SubcommandGroup),
]),
})
.array(),
]);
export const chatInputCommandPredicate = baseChatInputCommandPredicate.extend({
options: chatInputCommandOptionsPredicate.optional(),
});
export const chatInputCommandSubcommandGroupPredicate = sharedNameAndDescriptionPredicate.extend({
type: z.literal(ApplicationCommandOptionType.SubcommandGroup),
options: z
.array(z.object({ type: z.literal(ApplicationCommandOptionType.Subcommand) }))
.min(1)
.max(25),
});
export const chatInputCommandSubcommandPredicate = sharedNameAndDescriptionPredicate.extend({
type: z.literal(ApplicationCommandOptionType.Subcommand),
options: z.array(z.object({ type: basicOptionTypesPredicate })).max(25),
.union([
z.array(z.union(basicOptionPredicates)).max(25),
z.array(z.union([chatInputCommandSubcommandPredicate, chatInputCommandSubcommandGroupPredicate])).max(25),
])
.optional(),
type: z.literal(ApplicationCommandType.ChatInput).optional(),
});

View File

@@ -30,7 +30,7 @@ export class ChatInputCommandBuilder extends Mixin(
const data: RESTPostAPIChatInputApplicationCommandsJSONBody = {
...structuredClone(rest as Omit<RESTPostAPIChatInputApplicationCommandsJSONBody, 'options'>),
type: ApplicationCommandType.ChatInput,
options: options?.map((option) => option.toJSON(validationOverride)),
options: options?.map((option) => option.toJSON(false)),
};
validate(chatInputCommandPredicate, data, validationOverride);

View File

@@ -8,7 +8,6 @@ import type { z } from 'zod';
import { validate } from '../../../../util/validation.js';
import type { SharedNameAndDescriptionData } from '../../SharedNameAndDescription.js';
import { SharedNameAndDescription } from '../../SharedNameAndDescription.js';
import { basicOptionPredicate } from '../Assertions.js';
export interface ApplicationCommandOptionBaseData extends Partial<Pick<APIApplicationCommandOption, 'required'>> {
type: ApplicationCommandOptionType;
@@ -24,7 +23,7 @@ export abstract class ApplicationCommandOptionBase
/**
* @internal
*/
protected static readonly predicate: z.ZodType = basicOptionPredicate;
protected static readonly predicate: z.ZodType;
/**
* @internal

View File

@@ -1,10 +1,16 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { attachmentOptionPredicate } from '../Assertions.js';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command attachment option.
*/
export class ChatInputCommandAttachmentOption extends ApplicationCommandOptionBase {
/**
* @internal
*/
protected static override readonly predicate = attachmentOptionPredicate;
/**
* Creates a new attachment option.
*/

View File

@@ -1,10 +1,16 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { booleanOptionPredicate } from '../Assertions.js';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command boolean option.
*/
export class ChatInputCommandBooleanOption extends ApplicationCommandOptionBase {
/**
* @internal
*/
protected static override readonly predicate = booleanOptionPredicate;
/**
* Creates a new boolean option.
*/

View File

@@ -1,10 +1,16 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { mentionableOptionPredicate } from '../Assertions.js';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command mentionable option.
*/
export class ChatInputCommandMentionableOption extends ApplicationCommandOptionBase {
/**
* @internal
*/
protected static override readonly predicate = mentionableOptionPredicate;
/**
* Creates a new mentionable option.
*/

View File

@@ -1,10 +1,16 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { roleOptionPredicate } from '../Assertions.js';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command role option.
*/
export class ChatInputCommandRoleOption extends ApplicationCommandOptionBase {
/**
* @internal
*/
protected static override readonly predicate = roleOptionPredicate;
/**
* Creates a new role option.
*/

View File

@@ -1,10 +1,16 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { userOptionPredicate } from '../Assertions.js';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command user option.
*/
export class ChatInputCommandUserOption extends ApplicationCommandOptionBase {
/**
* @internal
*/
protected static override readonly predicate = userOptionPredicate;
/**
* Creates a new user option.
*/