Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/commandkit/src/CommandKit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import colors from './utils/colors';

export class CommandKit {
#data: CommandKitData;
static _instance: CommandKit | null = null;

/**
* Create a new command and event handler with CommandKit.
Expand All @@ -26,10 +27,25 @@ export class CommandKit {
}

this.#data = options;
CommandKit._instance = this;

this.#init();
}

/**
* Get the client attached to this CommandKit instance.
*/
get client() {
return this.#data.client;
}

/**
* Get command handler instance.
*/
get commandHandler() {
return this.#data.commandHandler;
}

/**
* (Private) Initialize CommandKit.
*/
Expand Down Expand Up @@ -70,6 +86,7 @@ export class CommandKit {
skipBuiltInValidations: this.#data.skipBuiltInValidations || false,
commandkitInstance: this,
bulkRegister: this.#data.bulkRegister || false,
enableHooks: this.#data.experimental?.hooks ?? false,
});

await commandHandler.init();
Expand Down
112 changes: 72 additions & 40 deletions packages/commandkit/src/handlers/command-handler/CommandHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CommandHandlerData, CommandHandlerOptions } from './typings';
import type { CommandHandlerData, CommandHandlerOptions, CommandKitInteraction } from './typings';
import type { CommandFileObject, ReloadOptions } from '../../typings';

import { toFileURL } from '../../utils/resolve-file-url';
Expand All @@ -9,12 +9,20 @@ import loadCommandsWithRest from './functions/loadCommandsWithRest';
import registerCommands from './functions/registerCommands';
import builtInValidationsFunctions from './validations';
import colors from '../../utils/colors';
import { AsyncLocalStorage } from 'async_hooks';
import type { CommandData } from '../../types';

export interface hCommandContext {
interaction: CommandKitInteraction;
command: CommandData;
}

/**
* A handler for client application commands.
*/
export class CommandHandler {
#data: CommandHandlerData;
context: AsyncLocalStorage<hCommandContext> | null = null;

constructor({ ...options }: CommandHandlerOptions) {
this.#data = {
Expand All @@ -25,6 +33,9 @@ export class CommandHandler {
}

async init() {
if (this.#data.enableHooks && !this.context) {
this.context = new AsyncLocalStorage();
}
await this.#buildCommands();

this.#buildBuiltInValidations();
Expand Down Expand Up @@ -157,6 +168,8 @@ export class CommandHandler {
}

handleCommands() {
const areHooksEnabled = this.#data.enableHooks;

this.#data.client.on('interactionCreate', async (interaction) => {
if (
!interaction.isChatInputCommand() &&
Expand All @@ -178,59 +191,78 @@ export class CommandHandler {
// Skip if autocomplete handler is not defined
if (isAutocomplete && !autocomplete) return;

const commandObj = {
data: targetCommand.data,
options: targetCommand.options,
...rest,
};
const executor = async () => {
const commandObj = {
data: targetCommand.data,
options: targetCommand.options,
...rest,
};

if (this.#data.validationHandler) {
let canRun = true;

for (const validationFunction of this.#data.validationHandler.validations) {
const stopValidationLoop = await validationFunction({
interaction,
commandObj,
client: this.#data.client,
handler: this.#data.commandkitInstance,
});

if (stopValidationLoop) {
canRun = false;
break;
}
}

if (this.#data.validationHandler) {
let canRun = true;
if (!canRun) return;
}

for (const validationFunction of this.#data.validationHandler.validations) {
const stopValidationLoop = await validationFunction({
interaction,
commandObj,
client: this.#data.client,
handler: this.#data.commandkitInstance,
});
let canRun = true;

if (stopValidationLoop) {
canRun = false;
break;
// If custom validations pass and !skipBuiltInValidations, run built-in CommandKit validation functions
if (!this.#data.skipBuiltInValidations) {
for (const validation of this.#data.builtInValidations) {
const stopValidationLoop = validation({
targetCommand,
interaction,
handlerData: this.#data,
});

if (stopValidationLoop) {
canRun = false;
break;
}
}
}

if (!canRun) return;
}

let canRun = true;
const command = targetCommand[isAutocomplete ? 'autocomplete' : 'run']!;

// If custom validations pass and !skipBuiltInValidations, run built-in CommandKit validation functions
if (!this.#data.skipBuiltInValidations) {
for (const validation of this.#data.builtInValidations) {
const stopValidationLoop = validation({
targetCommand,
// if hooks are not enabled, pass the context to the command via its arguments
if (!areHooksEnabled) {
const context = {
interaction,
handlerData: this.#data,
});

if (stopValidationLoop) {
canRun = false;
break;
}
client: this.#data.client,
handler: this.#data.commandkitInstance,
};
return await command(context);
}
}

if (!canRun) return;

const context = {
interaction,
client: this.#data.client,
handler: this.#data.commandkitInstance,
// @ts-expect-error - context data is not passed when hooks are enabled
return command();
};

await targetCommand[isAutocomplete ? 'autocomplete' : 'run']!(context);
if (this.context)
return this.context.run(
{
command: targetCommand.data,
interaction,
},
executor,
);
return executor();
});
}

Expand Down
17 changes: 13 additions & 4 deletions packages/commandkit/src/handlers/command-handler/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export interface CommandHandlerOptions {
* A boolean indicating whether to register all commands in bulk.
*/
bulkRegister: boolean;
/**
* Whether to enable hooks context.
*/
enableHooks: boolean;
}

/**
Expand Down Expand Up @@ -91,17 +95,22 @@ export interface BuiltInValidationParams {
/**
* The interaction of the target command.
*/
interaction:
| ChatInputCommandInteraction
| ContextMenuCommandInteraction
| AutocompleteInteraction;
interaction: CommandKitInteraction;

/**
* The command handler's data.
*/
handlerData: CommandHandlerData;
}

/**
* Represents a command interaction.
*/
export type CommandKitInteraction =
| ChatInputCommandInteraction
| ContextMenuCommandInteraction
| AutocompleteInteraction;

/**
* A built in validation. Returns a boolean or void.
*/
Expand Down
28 changes: 28 additions & 0 deletions packages/commandkit/src/hooks/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { CommandKit } from '..';

export function getCommandKit() {
return CommandKit._instance;
}

export function getCommandHandler() {
const handler = getCommandKit()?.commandHandler;

if (!handler) {
throw new Error('CommandKit is not initialized.');
}

return handler;
}

export function getContext() {
const info = getCommandHandler().context;
if (!info) {
throw new Error('Context is not available, did you forget to enable hooks?');
}

return info;
}

export function prepareHookInvocationError(name: string) {
return new Error(`Cannot invoke hook "${name}" outside of a command.`);
}
9 changes: 9 additions & 0 deletions packages/commandkit/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export { useClient } from './useClient';
export { useCommandKit } from './useCommandKit';
export { response } from './response';
export { useChannel } from './useChannel';
export { useCommandData } from './useCommandData';
export { useGuild } from './useGuild';
export { useInteraction } from './useInteraction';
export { useMember } from './useMember';
export { useUser } from './useUser';
30 changes: 30 additions & 0 deletions packages/commandkit/src/hooks/response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// this is partially based on sapphire's safelyReplyToInteraction helper

import type {
ChatInputCommandInteraction,
MessageComponentInteraction,
PartialTextBasedChannelFields,
} from 'discord.js';
import { useInteraction } from './useInteraction';
import { CommandKitInteraction } from '../handlers/command-handler/typings';

type ChatInputReplyData = Parameters<ChatInputCommandInteraction['reply']>[0];
type UpdateData = Parameters<MessageComponentInteraction['update']>[0];
type MessageData = Parameters<PartialTextBasedChannelFields['send']>[0] | ChatInputReplyData;

export async function response(data: MessageData) {
const interaction = useInteraction() as CommandKitInteraction | MessageComponentInteraction;

if (interaction.isAutocomplete()) return;

if (interaction.replied || interaction.deferred) {
await interaction.editReply(data);
} else if (interaction.isMessageComponent()) {
// TODO: this is not yet allowed in command handler
await interaction.update(data as UpdateData);
} else {
await interaction.reply(data as ChatInputReplyData);
}

// TODO: handle message based commands
}
10 changes: 10 additions & 0 deletions packages/commandkit/src/hooks/useChannel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { TextBasedChannel } from 'discord.js';
import { getContext, prepareHookInvocationError } from './common';

export function useChannel(): TextBasedChannel | null {
const data = getContext().getStore();

if (!data) throw prepareHookInvocationError('useChannel');

return data.interaction.channel;
}
5 changes: 5 additions & 0 deletions packages/commandkit/src/hooks/useClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { useCommandKit } from './useCommandKit';

export function useClient() {
return useCommandKit().client;
}
9 changes: 9 additions & 0 deletions packages/commandkit/src/hooks/useCommandData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { getContext, prepareHookInvocationError } from './common';

export function useCommandData() {
const data = getContext().getStore();

if (!data) throw prepareHookInvocationError('useCommandData');

return data.command;
}
8 changes: 8 additions & 0 deletions packages/commandkit/src/hooks/useCommandKit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getCommandKit } from './common';

export function useCommandKit() {
const kit = getCommandKit();
if (!kit) throw new Error('CommandKit is not initialized.');

return kit;
}
10 changes: 10 additions & 0 deletions packages/commandkit/src/hooks/useGuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Guild } from 'discord.js';
import { getContext, prepareHookInvocationError } from './common';

export function useGuild(): Guild | null {
const data = getContext().getStore();

if (!data) throw prepareHookInvocationError('useGuild');

return data.interaction.guild;
}
10 changes: 10 additions & 0 deletions packages/commandkit/src/hooks/useInteraction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { getContext, prepareHookInvocationError } from './common';
import { CommandKitInteraction } from '../handlers/command-handler/typings';

export function useInteraction<T extends CommandKitInteraction = CommandKitInteraction>(): T {
const data = getContext().getStore();

if (!data) throw prepareHookInvocationError('useInteraction');

return data.interaction as T;
}
10 changes: 10 additions & 0 deletions packages/commandkit/src/hooks/useMember.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { APIInteractionGuildMember, GuildMember } from 'discord.js';
import { getContext, prepareHookInvocationError } from './common';

export function useMember(): GuildMember | APIInteractionGuildMember | null {
const data = getContext().getStore();

if (!data) throw prepareHookInvocationError('useMember');

return data.interaction.member;
}
10 changes: 10 additions & 0 deletions packages/commandkit/src/hooks/useUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { User } from 'discord.js';
import { getContext, prepareHookInvocationError } from './common';

export function useUser(): User {
const data = getContext().getStore();

if (!data) throw prepareHookInvocationError('useUser');

return data.interaction.user;
}
1 change: 1 addition & 0 deletions packages/commandkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './CommandKit';
export * from './components';
export * from './config';
export * from './utils/signal';
export * from './hooks';
export type * from './types';
Loading