Skip to content
This repository was archived by the owner on Jan 8, 2022. It is now read-only.

Commit 37cce6b

Browse files
Merge branch 'main' of https://github.com/discordjs/builders into feat/components
2 parents 9575593 + b5d0b15 commit 37cce6b

20 files changed

+310
-249
lines changed

__tests__/SlashCommands.test.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,22 @@ describe('Slash Commands', () => {
207207

208208
expect(() => {
209209
const option = getStringOption();
210-
option.autocomplete = true;
211-
option.choices = [{ name: 'Fancy Pants', value: 'fp_1' }];
210+
Reflect.set(option, 'autocomplete', true);
211+
Reflect.set(option, 'choices', [{ name: 'Fancy Pants', value: 'fp_1' }]);
212+
return option.toJSON();
213+
}).toThrowError();
214+
215+
expect(() => {
216+
const option = getNumberOption();
217+
Reflect.set(option, 'autocomplete', true);
218+
Reflect.set(option, 'choices', [{ name: 'Fancy Pants', value: 'fp_1' }]);
219+
return option.toJSON();
220+
}).toThrowError();
221+
222+
expect(() => {
223+
const option = getIntegerOption();
224+
Reflect.set(option, 'autocomplete', true);
225+
Reflect.set(option, 'choices', [{ name: 'Fancy Pants', value: 'fp_1' }]);
212226
return option.toJSON();
213227
}).toThrowError();
214228
});
@@ -229,14 +243,6 @@ describe('Slash Commands', () => {
229243
expect(() => getBuilder().addChannelOption(getChannelOption().addChannelType(100))).toThrowError();
230244

231245
expect(() => getBuilder().addChannelOption(getChannelOption().addChannelTypes([100, 200]))).toThrowError();
232-
233-
expect(() => getBuilder().addChannelOption(getChannelOption().addChannelType(100))).toThrowError();
234-
235-
expect(() => getBuilder().addChannelOption(getChannelOption().addChannelType(1))).toThrowError();
236-
237-
expect(() => getBuilder().addChannelOption(getChannelOption().addChannelType(1))).toThrowError();
238-
239-
expect(() => getBuilder().addChannelOption(getChannelOption().addChannelTypes([1, 2, 3]))).toThrowError();
240246
});
241247

242248
test('GIVEN a builder with invalid number min/max options THEN does throw an error', () => {
@@ -324,6 +330,22 @@ describe('Slash Commands', () => {
324330
test('GIVEN valid builder with defaultPermission false THEN does not throw error', () => {
325331
expect(() => getBuilder().setName('foo').setDescription('foo').setDefaultPermission(false)).not.toThrowError();
326332
});
333+
334+
test('GIVEN an option that is autocompletable and has choices, THEN setting choices to an empty array should not throw an error', () => {
335+
expect(() =>
336+
getBuilder().addStringOption(getStringOption().setAutocomplete(true).setChoices([])),
337+
).not.toThrowError();
338+
});
339+
340+
test('GIVEN an option that is autocompletable and has choices, THEN setting choices should throw an error', () => {
341+
expect(() =>
342+
getBuilder().addStringOption(
343+
getStringOption()
344+
.setAutocomplete(true)
345+
.setChoices([['owo', 'uwu']]),
346+
),
347+
).toThrowError();
348+
});
327349
});
328350

329351
describe('Builder with subcommand (group) options', () => {

src/interactions/slashCommands/Assertions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import is from '@sindresorhus/is';
22
import type { APIApplicationCommandOptionChoice } from 'discord-api-types/v9';
33
import { z } from 'zod';
4-
import type { SlashCommandOptionBase } from './mixins/CommandOptionBase';
4+
import type { ApplicationCommandOptionBase } from './mixins/ApplicationCommandOptionBase';
55
import type { ToAPIApplicationCommandOptions } from './SlashCommandBuilder';
66
import type { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder } from './SlashCommandSubcommands';
77

@@ -57,7 +57,7 @@ export function validateMaxChoicesLength(choices: APIApplicationCommandOptionCho
5757
}
5858

5959
export function assertReturnOfBuilder<
60-
T extends SlashCommandOptionBase | SlashCommandSubcommandBuilder | SlashCommandSubcommandGroupBuilder,
60+
T extends ApplicationCommandOptionBase | SlashCommandSubcommandBuilder | SlashCommandSubcommandGroupBuilder,
6161
>(input: unknown, ExpectedInstanceOf: new () => T): asserts input is T {
6262
const instanceName = ExpectedInstanceOf.name;
6363

src/interactions/slashCommands/SlashCommandBuilder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
validateMaxOptionsLength,
77
validateRequiredParameters,
88
} from './Assertions';
9-
import { SharedSlashCommandOptions } from './mixins/CommandOptions';
9+
import { SharedSlashCommandOptions } from './mixins/SharedSlashCommandOptions';
1010
import { SharedNameAndDescription } from './mixins/NameAndDescription';
1111
import { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder } from './SlashCommandSubcommands';
1212

@@ -41,6 +41,7 @@ export class SlashCommandBuilder {
4141
*/
4242
public toJSON(): RESTPostAPIApplicationCommandsJSONBody {
4343
validateRequiredParameters(this.name, this.description, this.options);
44+
4445
return {
4546
name: this.name,
4647
description: this.description,

src/interactions/slashCommands/SlashCommandSubcommands.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { APIApplicationCommandSubCommandOptions, ApplicationCommandOptionType } from 'discord-api-types/v9';
1+
import {
2+
APIApplicationCommandSubcommandGroupOption,
3+
APIApplicationCommandSubcommandOption,
4+
ApplicationCommandOptionType,
5+
} from 'discord-api-types/v9';
26
import { mix } from 'ts-mixer';
37
import { assertReturnOfBuilder, validateMaxOptionsLength, validateRequiredParameters } from './Assertions';
4-
import { SharedSlashCommandOptions } from './mixins/CommandOptions';
8+
import type { ApplicationCommandOptionBase } from './mixins/ApplicationCommandOptionBase';
59
import { SharedNameAndDescription } from './mixins/NameAndDescription';
10+
import { SharedSlashCommandOptions } from './mixins/SharedSlashCommandOptions';
611
import type { ToAPIApplicationCommandOptions } from './SlashCommandBuilder';
712

813
/**
@@ -25,7 +30,7 @@ export class SlashCommandSubcommandGroupBuilder implements ToAPIApplicationComma
2530
/**
2631
* The subcommands part of this subcommand group
2732
*/
28-
public readonly options: ToAPIApplicationCommandOptions[] = [];
33+
public readonly options: SlashCommandSubcommandBuilder[] = [];
2934

3035
/**
3136
* Adds a new subcommand to this group
@@ -53,8 +58,9 @@ export class SlashCommandSubcommandGroupBuilder implements ToAPIApplicationComma
5358
return this;
5459
}
5560

56-
public toJSON(): APIApplicationCommandSubCommandOptions {
61+
public toJSON(): APIApplicationCommandSubcommandGroupOption {
5762
validateRequiredParameters(this.name, this.description, this.options);
63+
5864
return {
5965
type: ApplicationCommandOptionType.SubcommandGroup,
6066
name: this.name,
@@ -86,10 +92,11 @@ export class SlashCommandSubcommandBuilder implements ToAPIApplicationCommandOpt
8692
/**
8793
* The options of this subcommand
8894
*/
89-
public readonly options: ToAPIApplicationCommandOptions[] = [];
95+
public readonly options: ApplicationCommandOptionBase[] = [];
9096

91-
public toJSON(): APIApplicationCommandSubCommandOptions {
97+
public toJSON(): APIApplicationCommandSubcommandOption {
9298
validateRequiredParameters(this.name, this.description, this.options);
99+
93100
return {
94101
type: ApplicationCommandOptionType.Subcommand,
95102
name: this.name,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export abstract class ApplicationCommandNumericOptionMinMaxValueMixin {
2+
protected readonly maxValue?: number;
3+
protected readonly minValue?: number;
4+
5+
/**
6+
* Sets the maximum number value of this option
7+
* @param max The maximum value this option can be
8+
*/
9+
public abstract setMaxValue(max: number): this;
10+
11+
/**
12+
* Sets the minimum number value of this option
13+
* @param min The minimum value this option can be
14+
*/
15+
public abstract setMinValue(min: number): this;
16+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { APIApplicationCommandBasicOption, ApplicationCommandOptionType } from 'discord-api-types/v9';
2+
import { validateRequiredParameters, validateRequired } from '../Assertions';
3+
import { SharedNameAndDescription } from './NameAndDescription';
4+
5+
export abstract class ApplicationCommandOptionBase extends SharedNameAndDescription {
6+
public abstract readonly type: ApplicationCommandOptionType;
7+
8+
public readonly required = false;
9+
10+
/**
11+
* Marks the option as required
12+
*
13+
* @param required If this option should be required
14+
*/
15+
public setRequired(required: boolean) {
16+
// Assert that you actually passed a boolean
17+
validateRequired(required);
18+
19+
Reflect.set(this, 'required', required);
20+
21+
return this;
22+
}
23+
24+
public abstract toJSON(): APIApplicationCommandBasicOption;
25+
26+
protected runRequiredValidations() {
27+
validateRequiredParameters(this.name, this.description, []);
28+
29+
// Assert that you actually passed a boolean
30+
validateRequired(this.required);
31+
}
32+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { ChannelType } from 'discord-api-types/v9';
2+
import { z, ZodLiteral } from 'zod';
3+
4+
// Only allow valid channel types to be used. (This can't be dynamic because const enums are erased at runtime)
5+
const allowedChannelTypes = [
6+
ChannelType.GuildText,
7+
ChannelType.GuildVoice,
8+
ChannelType.GuildCategory,
9+
ChannelType.GuildNews,
10+
ChannelType.GuildStore,
11+
ChannelType.GuildNewsThread,
12+
ChannelType.GuildPublicThread,
13+
ChannelType.GuildPrivateThread,
14+
ChannelType.GuildStageVoice,
15+
] as const;
16+
17+
export type ApplicationCommandOptionAllowedChannelTypes = typeof allowedChannelTypes[number];
18+
19+
const channelTypePredicate = z.union(
20+
allowedChannelTypes.map((type) => z.literal(type)) as [
21+
ZodLiteral<ChannelType>,
22+
ZodLiteral<ChannelType>,
23+
...ZodLiteral<ChannelType>[]
24+
],
25+
);
26+
27+
export class ApplicationCommandOptionChannelTypesMixin {
28+
public readonly channel_types?: ApplicationCommandOptionAllowedChannelTypes[];
29+
30+
/**
31+
* Adds a channel type to this option
32+
*
33+
* @param channelType The type of channel to allow
34+
*/
35+
public addChannelType(channelType: ApplicationCommandOptionAllowedChannelTypes) {
36+
if (this.channel_types === undefined) {
37+
Reflect.set(this, 'channel_types', []);
38+
}
39+
40+
channelTypePredicate.parse(channelType);
41+
this.channel_types!.push(channelType);
42+
43+
return this;
44+
}
45+
46+
/**
47+
* Adds channel types to this option
48+
*
49+
* @param channelTypes The channel types to add
50+
*/
51+
public addChannelTypes(channelTypes: ApplicationCommandOptionAllowedChannelTypes[]) {
52+
channelTypes.forEach((channelType) => this.addChannelType(channelType));
53+
return this;
54+
}
55+
}
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,19 @@
1-
import {
2-
APIApplicationCommandOption,
3-
APIApplicationCommandOptionChoice,
4-
ApplicationCommandOptionType,
5-
} from 'discord-api-types/v9';
1+
import { APIApplicationCommandOptionChoice, ApplicationCommandOptionType } from 'discord-api-types/v9';
62
import { z } from 'zod';
73
import { validateMaxChoicesLength } from '../Assertions';
8-
import type { ToAPIApplicationCommandOptions } from '../SlashCommandBuilder';
9-
import { SlashCommandOptionBase } from './CommandOptionBase';
104

115
const stringPredicate = z.string().min(1).max(100);
126
const numberPredicate = z.number().gt(-Infinity).lt(Infinity);
137
const choicesPredicate = z.tuple([stringPredicate, z.union([stringPredicate, numberPredicate])]).array();
148
const booleanPredicate = z.boolean();
159

16-
export abstract class ApplicationCommandOptionWithChoicesBase<T extends string | number>
17-
extends SlashCommandOptionBase<
18-
ApplicationCommandOptionType.String | ApplicationCommandOptionType.Number | ApplicationCommandOptionType.Integer
19-
>
20-
implements ToAPIApplicationCommandOptions
21-
{
22-
public choices?: APIApplicationCommandOptionChoice[];
10+
export class ApplicationCommandOptionWithChoicesAndAutocompleteMixin<T extends string | number> {
11+
public readonly choices?: APIApplicationCommandOptionChoice<T>[];
2312
public readonly autocomplete?: boolean;
2413

14+
// Since this is present and this is a mixin, this is needed
15+
public readonly type!: ApplicationCommandOptionType;
16+
2517
/**
2618
* Adds a choice for this option
2719
*
@@ -33,18 +25,23 @@ export abstract class ApplicationCommandOptionWithChoicesBase<T extends string |
3325
throw new RangeError('Autocomplete and choices are mutually exclusive to each other.');
3426
}
3527

36-
this.choices ??= [];
28+
if (this.choices === undefined) {
29+
Reflect.set(this, 'choices', []);
30+
}
3731

38-
validateMaxChoicesLength(this.choices);
32+
validateMaxChoicesLength(this.choices!);
3933

4034
// Validate name
4135
stringPredicate.parse(name);
4236

4337
// Validate the value
44-
if (this.type === ApplicationCommandOptionType.String) stringPredicate.parse(value);
45-
else numberPredicate.parse(value);
38+
if (this.type === ApplicationCommandOptionType.String) {
39+
stringPredicate.parse(value);
40+
} else {
41+
numberPredicate.parse(value);
42+
}
4643

47-
this.choices.push({ name, value });
44+
this.choices!.push({ name, value });
4845

4946
return this;
5047
}
@@ -65,6 +62,23 @@ export abstract class ApplicationCommandOptionWithChoicesBase<T extends string |
6562
return this;
6663
}
6764

65+
public setChoices<Input extends [name: string, value: T][]>(
66+
choices: Input,
67+
): Input extends []
68+
? this & Pick<ApplicationCommandOptionWithChoicesAndAutocompleteMixin<T>, 'setAutocomplete'>
69+
: Omit<this, 'setAutocomplete'> {
70+
if (choices.length > 0 && this.autocomplete) {
71+
throw new RangeError('Autocomplete and choices are mutually exclusive to each other.');
72+
}
73+
74+
choicesPredicate.parse(choices);
75+
76+
Reflect.set(this, 'choices', []);
77+
for (const [label, value] of choices) this.addChoice(label, value);
78+
79+
return this;
80+
}
81+
6882
/**
6983
* Marks the option as autocompletable
7084
* @param autocomplete If this option should be autocompletable
@@ -73,7 +87,7 @@ export abstract class ApplicationCommandOptionWithChoicesBase<T extends string |
7387
autocomplete: U,
7488
): U extends true
7589
? Omit<this, 'addChoice' | 'addChoices'>
76-
: this & Pick<ApplicationCommandOptionWithChoicesBase<T>, 'addChoice' | 'addChoices'> {
90+
: this & Pick<ApplicationCommandOptionWithChoicesAndAutocompleteMixin<T>, 'addChoice' | 'addChoices'> {
7791
// Assert that you actually passed a boolean
7892
booleanPredicate.parse(autocomplete);
7993

@@ -85,18 +99,4 @@ export abstract class ApplicationCommandOptionWithChoicesBase<T extends string |
8599

86100
return this;
87101
}
88-
89-
public override toJSON() {
90-
if (this.autocomplete && Array.isArray(this.choices) && this.choices.length > 0) {
91-
throw new RangeError('Autocomplete and choices are mutually exclusive to each other.');
92-
}
93-
94-
// TODO: Fix types
95-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
96-
return {
97-
...super.toJSON(),
98-
choices: this.choices,
99-
autocomplete: this.autocomplete,
100-
} as APIApplicationCommandOption;
101-
}
102102
}

0 commit comments

Comments
 (0)