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', () => { describe('Subcommand builder and subcommand group builder', () => {
test('GIVEN both types THEN does not throw error', () => { test('GIVEN both types THEN does not throw error', () => {
expect(() => expect(() =>
getBuilder() getNamedBuilder()
.setName('test') .addSubcommands(getSubcommand())
.setDescription('Test command') .addSubcommandGroups(getSubcommandGroup().addSubcommands(getSubcommand()))
.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'),
),
)
.toJSON(), .toJSON(),
).not.toThrowError(); ).not.toThrowError();
}); });

View File

@@ -2,6 +2,7 @@ import {
ApplicationIntegrationType, ApplicationIntegrationType,
InteractionContextType, InteractionContextType,
ApplicationCommandOptionType, ApplicationCommandOptionType,
ApplicationCommandType,
} from 'discord-api-types/v10'; } from 'discord-api-types/v10';
import { z } from 'zod'; import { z } from 'zod';
import { localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js'; import { localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js';
@@ -47,38 +48,55 @@ const choiceBasePredicate = z.object({
name: choiceValueStringPredicate, name: choiceValueStringPredicate,
name_localizations: localeMapPredicate.optional(), name_localizations: localeMapPredicate.optional(),
}); });
const choiceStringPredicate = choiceBasePredicate.extend({ const choiceStringPredicate = z.object({
...choiceBasePredicate.shape,
value: choiceValueStringPredicate, value: choiceValueStringPredicate,
}); });
const choiceNumberPredicate = choiceBasePredicate.extend({ const choiceNumberPredicate = z.object({
...choiceBasePredicate.shape,
value: choiceValueNumberPredicate, value: choiceValueNumberPredicate,
}); });
const choiceBaseMixinPredicate = z.object({ const choiceBaseMixinPredicate = z.object({
autocomplete: z.literal(false).optional(), autocomplete: z.literal(false).optional(),
}); });
const choiceStringMixinPredicate = choiceBaseMixinPredicate.extend({ const choiceStringMixinPredicate = z.object({
...choiceBaseMixinPredicate.shape,
choices: choiceStringPredicate.array().max(25).optional(), choices: choiceStringPredicate.array().max(25).optional(),
}); });
const choiceNumberMixinPredicate = choiceBaseMixinPredicate.extend({ const choiceNumberMixinPredicate = z.object({
...choiceBaseMixinPredicate.shape,
choices: choiceNumberPredicate.array().max(25).optional(), choices: choiceNumberPredicate.array().max(25).optional(),
}); });
const basicOptionTypesPredicate = z.literal([ export const baseBasicOptionPredicate = z.object({
ApplicationCommandOptionType.Attachment, ...sharedNameAndDescriptionPredicate.shape,
ApplicationCommandOptionType.Boolean,
ApplicationCommandOptionType.Channel,
ApplicationCommandOptionType.Integer,
ApplicationCommandOptionType.Mentionable,
ApplicationCommandOptionType.Number,
ApplicationCommandOptionType.Role,
ApplicationCommandOptionType.String,
ApplicationCommandOptionType.User,
]);
export const basicOptionPredicate = sharedNameAndDescriptionPredicate.extend({
required: z.boolean().optional(), 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', [ const autocompleteOrStringChoicesMixinOptionPredicate = z.discriminatedUnion('autocomplete', [
@@ -92,64 +110,71 @@ const autocompleteOrNumberChoicesMixinOptionPredicate = z.discriminatedUnion('au
]); ]);
export const channelOptionPredicate = z.object({ export const channelOptionPredicate = z.object({
...basicOptionPredicate.shape, ...baseBasicOptionPredicate.shape,
...channelMixinOptionPredicate.shape, ...channelMixinOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Channel),
}); });
export const integerOptionPredicate = z export const integerOptionPredicate = z
.object({ .object({
...basicOptionPredicate.shape, ...baseBasicOptionPredicate.shape,
...numericMixinIntegerOptionPredicate.shape, ...numericMixinIntegerOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Integer),
}) })
.and(autocompleteOrNumberChoicesMixinOptionPredicate); .and(autocompleteOrNumberChoicesMixinOptionPredicate);
export const numberOptionPredicate = z export const numberOptionPredicate = z
.object({ .object({
...basicOptionPredicate.shape, ...baseBasicOptionPredicate.shape,
...numericMixinNumberOptionPredicate.shape, ...numericMixinNumberOptionPredicate.shape,
type: z.literal(ApplicationCommandOptionType.Number),
}) })
.and(autocompleteOrNumberChoicesMixinOptionPredicate); .and(autocompleteOrNumberChoicesMixinOptionPredicate);
export const stringOptionPredicate = basicOptionPredicate export const stringOptionPredicate = z
.extend({ .object({
...baseBasicOptionPredicate.shape,
max_length: z.number().min(1).max(6_000).optional(), max_length: z.number().min(1).max(6_000).optional(),
min_length: z.number().min(0).max(6_000).optional(), min_length: z.number().min(0).max(6_000).optional(),
type: z.literal(ApplicationCommandOptionType.String),
}) })
.and(autocompleteOrStringChoicesMixinOptionPredicate); .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(), contexts: z.array(z.enum(InteractionContextType)).optional(),
default_member_permissions: memberPermissionsPredicate.optional(), default_member_permissions: memberPermissionsPredicate.optional(),
integration_types: z.array(z.enum(ApplicationIntegrationType)).optional(), integration_types: z.array(z.enum(ApplicationIntegrationType)).optional(),
nsfw: z.boolean().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 options: z
.array(z.object({ type: z.literal(ApplicationCommandOptionType.Subcommand) })) .union([
.min(1) z.array(z.union(basicOptionPredicates)).max(25),
.max(25), z.array(z.union([chatInputCommandSubcommandPredicate, chatInputCommandSubcommandGroupPredicate])).max(25),
}); ])
.optional(),
export const chatInputCommandSubcommandPredicate = sharedNameAndDescriptionPredicate.extend({ type: z.literal(ApplicationCommandType.ChatInput).optional(),
type: z.literal(ApplicationCommandOptionType.Subcommand),
options: z.array(z.object({ type: basicOptionTypesPredicate })).max(25),
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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