Skip to content

Commit

Permalink
Merge pull request #12 from Mimickal/ts-subcommand-fix
Browse files Browse the repository at this point in the history
  • Loading branch information
Mimickal authored Sep 2, 2023
2 parents 20fc7f8 + a6adc20 commit 5b10d1d
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 160 deletions.
254 changes: 144 additions & 110 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "discord-command-registry",
"version": "2.2.0",
"version": "2.2.1",
"description": "A structure for Discord.js slash commands that allow you to define, register, and execute slash commands all in one place.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
Expand Down Expand Up @@ -55,7 +55,7 @@
"nyc": "^15.1.0",
"ts-mixer": "^6.0.3",
"ts-node": "^10.9.1",
"typescript": "^5.0.4"
"typescript": "^5.2.2"
},
"dependencies": {
"commander": "^10.0.1"
Expand Down
57 changes: 22 additions & 35 deletions src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,20 @@ import * as Discord from 'discord.js';
import { Mixin } from 'ts-mixer';
import { s } from '@sapphire/shapeshift';

const { name: pack_name } = require('../package.json');
const { name: PACKAGE_NAME } = require('../package.json');

/** Either a Builder or a function that returns a Builder. */
export type BuilderInput<T> = T | ((thing: T) => T);
/** The function called during command execution. */
export type Handler = (interaction: Discord.CommandInteraction) => unknown;
export type Handler<T extends Discord.CommandInteraction> = (interaction: T) => unknown;
/** A string option with the length set internally. */
export type SlashCommandCustomOption = Omit<Discord.SlashCommandStringOption,
'setMinLength' | 'setMaxLength'
>;

/** Mixin that adds builder support for our additional custom options. */
class MoreOptionsMixin extends Discord.SharedSlashCommandOptions {
class MoreOptionsMixin<ShouldOmitSubcommandFunctions = true>
extends Discord.SharedSlashCommandOptions<ShouldOmitSubcommandFunctions> {
/**
* Adds an Application option.
*
Expand Down Expand Up @@ -82,12 +83,12 @@ class MoreOptionsMixin extends Discord.SharedSlashCommandOptions {
}

/** Mixin that adds the ability to set and store a command handler function. */
class CommandHandlerMixin {
class CommandHandlerMixin<T extends Discord.CommandInteraction> {
/** The function called when this command is executed. */
public readonly handler: Handler | undefined;
public readonly handler: Handler<T> | undefined;

/** Sets the function called when this command is executed. */
setHandler(handler: Handler): this {
setHandler(handler: Handler<T>): this {
if (typeof handler !== 'function') {
throw new Error(`handler was '${typeof handler}', expected 'function'`);
}
Expand All @@ -97,50 +98,36 @@ class CommandHandlerMixin {
}
}

/**
* Discord.js builders are not designed to be grouped together in a collection.
* This union represents any possible end value for an individual command's
* builder.
*/
export type SlashCommandBuilderReturn =
| SlashCommandBuilder
| Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>
| SlashCommandSubcommandsOnlyBuilder;

type SlashCommandSubcommandsOnlyBuilder = Omit<SlashCommandBuilder,
| Exclude<keyof Discord.SharedSlashCommandOptions, 'options'>
| keyof MoreOptionsMixin
>;

// NOTE: it's important that Discord's built-ins are the last Mixin in the list!
// Otherwise, we run the risk of stepping on field initialization.

export class ContextMenuCommandBuilder extends Mixin(
CommandHandlerMixin,
// This double mixin is dumb, but it's the only way to accept both
// ContextMenuCommandInteraction types.
CommandHandlerMixin<Discord.UserContextMenuCommandInteraction>,
CommandHandlerMixin<Discord.MessageContextMenuCommandInteraction>,
Discord.ContextMenuCommandBuilder,
) {}

export class SlashCommandBuilder extends Mixin(
CommandHandlerMixin,
MoreOptionsMixin,
CommandHandlerMixin<Discord.ChatInputCommandInteraction>,
MoreOptionsMixin<false>,
Discord.SlashCommandBuilder,
) {
// @ts-ignore We want to force this to only accept our version of the
// @ts-expect-error We want to force this to only accept our version of the
// builder with .setHandler.
addSubcommand(
input: BuilderInput<SlashCommandSubcommandBuilder>
): SlashCommandSubcommandsOnlyBuilder {
addSubcommand(input: BuilderInput<SlashCommandSubcommandBuilder>): this {
return addThing(this, input,
SlashCommandSubcommandBuilder,
Discord.SlashCommandSubcommandBuilder
);
}

// @ts-ignore We want to force this to only accept our version of the
// @ts-expect-error We want to force this to only accept our version of the
// builder with .setHandler.
addSubcommandGroup(
input: BuilderInput<SlashCommandSubcommandGroupBuilder>
): SlashCommandSubcommandsOnlyBuilder {
): this {
return addThing(this, input,
SlashCommandSubcommandGroupBuilder,
Discord.SlashCommandSubcommandGroupBuilder,
Expand All @@ -149,10 +136,10 @@ export class SlashCommandBuilder extends Mixin(
}

export class SlashCommandSubcommandGroupBuilder extends Mixin(
CommandHandlerMixin,
CommandHandlerMixin<Discord.ChatInputCommandInteraction>,
Discord.SlashCommandSubcommandGroupBuilder,
) {
// @ts-ignore We want to force this to only accept our version of the
// @ts-expect-error We want to force this to only accept our version of the
// builder with .setHandler.
addSubcommand(input: BuilderInput<SlashCommandSubcommandBuilder>): this {
return addThing(this, input,
Expand All @@ -163,8 +150,8 @@ export class SlashCommandSubcommandGroupBuilder extends Mixin(
}

export class SlashCommandSubcommandBuilder extends Mixin(
MoreOptionsMixin,
CommandHandlerMixin,
MoreOptionsMixin<false>,
CommandHandlerMixin<Discord.ChatInputCommandInteraction>,
Discord.SlashCommandSubcommandBuilder,
) {}

Expand Down Expand Up @@ -200,7 +187,7 @@ export function assertReturnOfBuilder<T, P>(
): asserts input is T {
if (!(input instanceof ExpectedInstanceOf)) {
throw new Error(ParentInstanceOf && input instanceof ParentInstanceOf
? `Use ${ExpectedInstanceOf.name} from ${pack_name}, not discord.js`
? `Use ${ExpectedInstanceOf.name} from ${PACKAGE_NAME}, not discord.js`
: `input did not resolve to a ${ExpectedInstanceOf.name}. Got ${input}`
);
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
export * from '@discordjs/builders'; // Forward utils and stuff
export {
ContextMenuCommandBuilder,
Handler,
SlashCommandBuilder,
SlashCommandCustomOption,
SlashCommandSubcommandBuilder,
Expand Down
25 changes: 14 additions & 11 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@
import {
BaseInteraction,
ChatInputCommandInteraction,
CommandInteraction,
ContextMenuCommandBuilder as DiscordContextMenuCommandBuilder,
ContextMenuCommandInteraction,
DiscordAPIError,
ContextMenuCommandBuilder as DiscordContextMenuCommandBuilder,
SlashCommandBuilder as DiscordSlashCommandBuilder,
REST,
RESTPostAPIChatInputApplicationCommandsJSONBody,
RESTPostAPIContextMenuApplicationCommandsJSONBody,
Routes,
SlashCommandBuilder as DiscordSlashCommandBuilder,
Snowflake,
RESTPostAPIContextMenuApplicationCommandsJSONBody,
RESTPostAPIChatInputApplicationCommandsJSONBody,
CommandInteraction,
} from 'discord.js';

import {
Expand All @@ -29,14 +29,12 @@ import {
Handler,
resolveBuilder,
SlashCommandBuilder,
SlashCommandBuilderReturn,
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from './builders';
import { API_VERSION } from './constants';

/** A top-level command builder. */
type TopLevelBuilder = SlashCommandBuilderReturn | ContextMenuCommandBuilder;
type TopLevelBuilder = ContextMenuCommandBuilder | SlashCommandBuilder

/** Optional parameters for registering commands. */
interface RegisterOpts {
Expand Down Expand Up @@ -77,7 +75,7 @@ export default class SlashCommandRegistry {
application_id: Snowflake | null = null;

/** The handler run for unrecognized commands. */
default_handler: Handler | null = null;
default_handler: Handler<CommandInteraction> | null = null;

/** A Discord guild ID used to restrict command registration to one guild. */
guild_id: Snowflake | null = null;
Expand All @@ -104,7 +102,7 @@ export default class SlashCommandRegistry {
* @throws If input does not resolve to a SlashCommandBuilder.
* @return Instance so we can chain calls.
*/
addCommand(input: BuilderInput<SlashCommandBuilderReturn>): this {
addCommand(input: BuilderInput<SlashCommandBuilder>): this {
const builder = resolveBuilder(input, SlashCommandBuilder);
assertReturnOfBuilder(builder,
SlashCommandBuilder,
Expand Down Expand Up @@ -154,7 +152,7 @@ export default class SlashCommandRegistry {
* @throws If handler is not a function.
* @return Instance so we can chain calls.
*/
setDefaultHandler(handler: Handler): this {
setDefaultHandler(handler: Handler<CommandInteraction>): this {
if (typeof handler !== 'function') {
throw new Error(`handler was '${typeof handler}', expected 'function'`);
}
Expand Down Expand Up @@ -297,6 +295,11 @@ export default class SlashCommandRegistry {
builder_top.handler ??
this.default_handler;

// @ts-expect-error Discord.js Interaction types are mutually exclusive,
// despite all extending BaseInteraction. We do our best to make sure
// each individual handler is the right type, but the union of all of
// them here resolves to "never".
// https://discord.com/channels/222078108977594368/824411059443204127/1145960025962066033
return handler ? handler(interaction) as T : undefined;
}

Expand Down
32 changes: 30 additions & 2 deletions test/builders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,48 @@ import * as Discord from 'discord.js';

import {
ContextMenuCommandBuilder,
Handler,
SlashCommandCustomOption,
SlashCommandBuilder,
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
SlashCommandRegistry,
} from '../src';
import { BuilderInput, Handler } from '../src/builders';
import { BuilderInput } from '../src/builders';

type CanSetHandler = new () => {
setHandler: (handler: Handler) => unknown;
setHandler: (handler: Handler<Discord.CommandInteraction>) => unknown;
}
type CanAddSubCommand = new () => {
addSubcommand: (input: BuilderInput<SlashCommandSubcommandBuilder>) => unknown;
};

// These are static type tests to ensure Handler can accept all of these types.
new SlashCommandRegistry()
// Can accept all ContextMenuCommandInteraction types
.addContextMenuCommand(cmd => cmd
.setType(Discord.ApplicationCommandType.User)
.setHandler((int: Discord.UserContextMenuCommandInteraction) => {})
.setHandler((int: Discord.MessageContextMenuCommandInteraction) => {})
)
// Can accept ChatInputCommandInteractions
.addCommand(cmd => cmd
.setHandler((int: Discord.CommandInteraction) => {})
.setHandler((int: Discord.ChatInputCommandInteraction) => {})
)
// Can accept a fallback handler
.setDefaultHandler((int: Discord.CommandInteraction) => {})
// Can accept commands with options
.addCommand(cmd => cmd
.addChannelOption(opt => opt)
)
// Can accept subcommands with options
.addCommand(cmd => cmd
.addSubcommand(sub => sub
.addChannelOption(opt => opt)
)
)

describe('Builders have setHandler() functions injected', function() {
Array.of<CanSetHandler>(
ContextMenuCommandBuilder,
Expand Down
1 change: 1 addition & 0 deletions test/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class MockCommandInteraction extends ChatInputCommandInteraction {
username: 'fake_test_user',
discriminator: '1234',
avatar: null,
global_name: null,
},
});

Expand Down

0 comments on commit 5b10d1d

Please sign in to comment.