Skip to content

Commit

Permalink
add option builder
Browse files Browse the repository at this point in the history
  • Loading branch information
paperdave committed May 31, 2022
1 parent 4af1e73 commit a4980c7
Show file tree
Hide file tree
Showing 8 changed files with 296 additions and 15 deletions.
9 changes: 9 additions & 0 deletions .changeset/calm-cobras-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'purplet': patch
---

- add `$appCommand`
- add `$userCommand`
- add `$djsUserCommand`
- add `$messageCommand`
- add `$djsMessageCommand`
5 changes: 5 additions & 0 deletions .changeset/lovely-mugs-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'purplet': patch
---

Add PurpletInteraction and all of it's subclasses.
5 changes: 5 additions & 0 deletions .changeset/strange-bananas-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'purplet': patch
---

add OptionBuilder
32 changes: 18 additions & 14 deletions examples/basic/src/modules/first.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EmbedBuilder, InteractionResponseType } from 'discord.js';
import { $djsUserCommand, $userCommand } from 'purplet';
import { $userCommand, OptionBuilder } from 'purplet';

export const getInfo1 = $userCommand({
name: 'Get Info (purplet)',
Expand All @@ -19,16 +19,20 @@ export const getInfo1 = $userCommand({
},
});

export const getInfo2 = $djsUserCommand({
name: 'Get Info (discord.js)',
handle(target) {
this.reply({
embeds: [
new EmbedBuilder()
.setTitle('User')
.setDescription('```json\n' + JSON.stringify(target.toJSON(), null, 2) + '\n```')
.toJSON(),
],
});
},
});
const x = new OptionBuilder()
.string('name', 'your name goes here', {
required: true,
})
.string('last_name', 'your last name goes here', {
required: false,
})
.string('ice_cream_flavor', 'what do you want', {
choices: {
vanilla: 'Vanilla',
chocolate: 'Chocolate',
strawberry: 'Strawberry',
mint: 'Mint',
},
});

x;
1 change: 1 addition & 0 deletions packages/purplet/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Remove after Node.js 16 is no longer in LTS
import '@ungap/structured-clone';

export * from './lib/builders/OptionBuilder';
export * from './lib/feature';
export * from './lib/gateway';
export { djs, rest } from './lib/global';
Expand Down
186 changes: 186 additions & 0 deletions packages/purplet/src/lib/builders/OptionBuilder.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/* eslint-disable no-redeclare */
import type { Awaitable, Class, ForceSimplify } from '@davecode/types';
import { LocalizationMap } from 'discord-api-types/payloads/common';
import {
APIApplicationCommandOption,
APIAttachment,
APIInteractionDataResolvedChannel,
APIRole,
APIUser,
ChannelType,
} from 'discord.js';
import type { PurpletAutocompleteInteraction } from '../interaction';

/**
* (Explainer part 1 of 2)
*
* OptionBuilder is built out of a lot of mapped types, to reduce the amount of copied code. This
* builder is special because it keeps track of EVERYTHING passed to it in a type parameter, which
* can be extracted later, giving you strong types inside of `$chatCommand`.
*
* The first two types, `OptionInputs` and `OptionOutputs` are interfaces mapping method names to
* their respective input and output. "Input" in this case refers to the third argument, containing
* options after name and description. All of these inputs extend other interfaces according to the
* discord api, but with camel case names. "Output" refers to the resolved data types.
*/

/** @internal Maps builder method names to what the third argument should be. */
interface OptionInputs<ThisKey = string, ExistingOptions = null> {
string: EnumOption<ThisKey, ExistingOptions, string>;
integer: NumericOption<ThisKey, ExistingOptions>;
boolean: BaseOption;
channel: ChannelOption;
user: BaseOption;
mentionable: BaseOption;
role: BaseOption;
number: NumericOption<ThisKey, ExistingOptions>;
attachment: BaseOption;
}

/** @internal Maps builder method names to what the option resolves to. */
interface OptionTypeValues {
string: string;
integer: number;
boolean: boolean;
channel: APIInteractionDataResolvedChannel;
user: APIUser;
mentionable: APIUser | APIRole;
role: APIRole;
number: number;
attachment: APIAttachment;
}

/** @internal Used for `OptionInputs`. Hold the common properties of all options. */
interface BaseOption {
nameLocalizations?: LocalizationMap;
descriptionLocalizations?: LocalizationMap;
}

/** @internal Used for `OptionInputs`. This option has an autocomplete handler */
interface AutocompleteOption<ThisKey, ExistingOptions, T> extends BaseOption {
autocomplete?: Autocomplete<Partial<ExistingOptions> & Record<ThisKey, T>, T>;
}

/**
* @internal Used for `OptionInputs`. Enum Options as I call them are anything with a dropdown list.
* In Discord, this is done with a `choices` list or `autocomplete` handler.
*/
type EnumOption<ThisKey, ExistingOptions, T> =
| AutocompleteOption<ThisKey, ExistingOptions, T>
| (BaseOption & {
// Look at this: it's an object, NOT an array. Personal opinion tbh, looks cleaner.
choices: Record<T, string>;
choiceLocalizations?: Record<T, LocalizationMap>;
});

/** @internal Used for `OptionInputs`. Numeric options have a min and max value, in addition to being enum-able. */
interface NumericOption<ThisKey, ExistingOptions>
extends EnumOption<ThisKey, ExistingOptions, number> {
minValue?: number;
maxValue?: number;
}

/** @internal Used for `OptionInputs`. Channels have a channel type limit, but are otherwise basic options. */
interface ChannelOption extends BaseOption {
channelTypes?: ChannelType[];
}

/**
* The rest of the option types are BaseOption, as they have no extra properties.
*
* Next up are a few utility types for stuff youre passing to the builder for choices and autocomplete.
*/

/** Represents one choice from a `.choices` object. */
export interface Choice<T> {
name: string;
nameLocalizations?: LocalizationMap;
value: T;
}

/**
* Represents an option autocomplete handler passed to `.autocomplete` on an option builder's
* options argument. This function gets called on autocomplete interactions tied to whatever command
* option you pass it to.
*/
export type Autocomplete<ExistingOptions = Record<string, never>, Type = unknown> = (
this: PurpletAutocompleteInteraction,
ctx: ExistingOptions
) => Awaitable<Choice<Type>[]>;

/**
* Now, for building the actual OptionBuilder instance type. We simply map over the keys of the
* inputs with an `OptionBuilderMethod`. This is also one of our exported types, so the main tsdoc
* is right here.
*/

/**
* `OptionBuilder` is an advanced builder class for `CHAT_INPUT` command's `options` property,
* keeping contents of.
*/
export type OptionBuilder<Options = {}> = {
/** Append an option. */
[Type in keyof OptionInputs]: OptionBuilderMethod<Options, Type>;
} & {
/** Converts the builder into an `APIApplicationCommandOption[]`, suitible to be sent to the Discord API. */
toJSON(): APIApplicationCommandOption[];
};

/**
* Given an object of our current options `CurrentOptions` and the method name `MethodName`, resolve
* to a method with three type parameters, which all get inferred by its usage.
*/
type OptionBuilderMethod<CurrentOptions, MethodName extends keyof OptionInputs> = <
/** `Key` will match the first argument, any string for the option name. */
Key extends string,
/**
* `OptionOptions` will match the third argument, the options that this option gets, as defined
* from `OptionInputs` up above. We also pass it the `ThisKey` and `ExistingOptions` params, which
* are only really used for the `autocomplete` method.
*/
OptionOptions extends OptionInputs<Key, CurrentOptions>[MethodName],
/**
* `IsRequired` is a boolean that will match the extra { required?: boolean } object we join
* `OptionOptions` with, as we perform different type behavior based on whether or not it's
* required or not.
*/
IsRequired extends boolean = false
>(
key: Key,
desc: string,
options?: OptionOptions & { required?: IsRequired }
) => OptionBuilder<
/**
* The new property is the current options joined with the new option. The `RequiredIf` helper
* type converts the new Record into a partial if `required` is set to false.
*/
ForceSimplify<
CurrentOptions &
RequiredIf<
IsRequired,
Record<
Key,
/** Here, we need to resolve EnumOptions, aka stuff with a choice array. */
OptionOptions extends EnumOption<unknown, unknown, infer T>
? T
: OptionTypeValues[MethodName]
>
>
>
>;

/** If `If` is false, then `T` is returned as a partial, otherwise it is returned as normal. */
type RequiredIf<If, T> = If extends false ? Partial<T> : T;

// the actual class definition
export const OptionBuilder: Class<OptionBuilder>;

/** Extract the Record<string, Autocomplete> out of an OptionBuilder. */
export function getAutoCompleteHandlersFromBuilder(
builder: OptionBuilder | undefined
): Record<string, Autocomplete>;

/** Utility type to extract the option types out of an `OptionBuilder` */
export type GetOptionsFromBuilder<T extends OptionBuilder> = T extends OptionBuilder<infer U>
? U
: never;
71 changes: 71 additions & 0 deletions packages/purplet/src/lib/builders/OptionBuilder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// be careful changing this file

export class OptionBuilder {
options = [];
autocompleteHandlers = {};

#createOption(type) {
return (name, description, opts = {}) => {
const obj = {
type,
name,
description,
min_value: opts.minValue ?? undefined,
max_value: opts.maxValue ?? undefined,
channel_types: opts.channelTypes ?? undefined,
name_localizations: opts.nameLocalizations ?? undefined,
description_localizations: opts.descriptionLocalizations ?? undefined,
};

if (obj.required === undefined) {
obj.required = false;
}

if (opts.autocomplete) {
this.autocompleteHandlers[name] = opts.autocomplete;
obj.autocomplete = true;
}

if (opts.choices) {
obj.choices = Object.entries(opts.choices).map(([value, displayName]) => {
// value may be converted to string, undo that:
if (type === 'NUMBER' || type === 'INTEGER') {
value = Number(value);
}

return {
name: displayName,
name_localizations: opts.choiceLocalizations?.[key] ?? undefined,
value,
};
});
}

this.options.push(obj);

return this;
};
}

string = this.#createOption('STRING');
integer = this.#createOption('INTEGER');
boolean = this.#createOption('BOOLEAN');
channel = this.#createOption('CHANNEL');
user = this.#createOption('USER');
mentionable = this.#createOption('MENTIONABLE');
role = this.#createOption('ROLE');
number = this.#createOption('NUMBER');
attachment = this.#createOption('ATTACHMENT');

toJSON() {
return this.options;
}
}

export function getOptionsFromBuilder(builder) {
return builder ? builder.options : [];
}

export function getAutoCompleteHandlersFromBuilder(builder) {
return builder ? builder.autocompleteHandlers : {};
}
2 changes: 1 addition & 1 deletion packages/purplet/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@
"strict": true,
"target": "ESNext"
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.js"],
"exclude": ["**/node_modules", "**/dist"]
}

0 comments on commit a4980c7

Please sign in to comment.