slash-create
Version:
Create and sync Discord slash commands!
634 lines (633 loc) • 30.5 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseSlashCreator = void 0;
const eventemitter3_1 = __importDefault(require("eventemitter3"));
const util_1 = require("./util");
const constants_1 = require("./constants");
const collection_1 = require("./util/collection");
const api_1 = require("./api");
const commandContext_1 = require("./structures/interfaces/commandContext");
const lodash_isequal_1 = __importDefault(require("lodash.isequal"));
const componentContext_1 = require("./structures/interfaces/componentContext");
const autocompleteContext_1 = require("./structures/interfaces/autocompleteContext");
const modalInteractionContext_1 = require("./structures/interfaces/modalInteractionContext");
const requestHandler_1 = require("./rest/requestHandler");
/** The base class for SlashCreators. */
class BaseSlashCreator extends eventemitter3_1.default {
/** @param opts The options for the creator */
constructor(opts, requestHandlerOverrides) {
// eslint-disable-next-line constructor-super
super();
/** The API handler for the creator */
this.api = new api_1.SlashCreatorAPI(this);
/** The commands loaded onto the creator */
this.commands = new collection_1.Collection();
/** @hidden */
this._componentCallbacks = new Map();
/** @hidden */
this._modalCallbacks = new Map();
if (!opts.applicationID)
throw new Error('An application ID must be defined!');
if (opts.token && !opts.token.startsWith('Bot ') && !opts.token.startsWith('Bearer '))
opts.token = 'Bot ' + opts.token;
this.client = opts.client;
// Define default options
this.options = Object.assign({
agent: null,
allowedMentions: {
users: true,
roles: true
},
defaultImageFormat: 'jpg',
defaultImageSize: 128,
unknownCommandResponse: true,
handleCommandsManually: false,
disableTimeouts: false,
componentTimeouts: false,
postCallbacks: false,
latencyThreshold: 30000,
ratelimiterOffset: 0,
requestTimeout: 15000,
endpointPath: '/interactions',
serverPort: 8030,
serverHost: 'localhost'
}, opts);
this.allowedMentions = (0, util_1.formatAllowedMentions)(this.options.allowedMentions);
this.requestHandler = new requestHandler_1.RequestHandler(this, {
...(this.options.rest ?? {}),
token: this.options.token,
overrides: requestHandlerOverrides
});
this.api = new api_1.SlashCreatorAPI(this);
}
/**
* Registers a single command
* @param command Either a Command instance, or a constructor for one
* @see SlashCreator#registerCommands
*/
registerCommand(command) {
if (typeof command === 'function')
command = new command(this);
else if (typeof command.default === 'function')
command = new command.default(this);
if (command.creator !== this)
throw new Error(`Invalid command object to register: ${command}`);
const slashCommand = command;
// Make sure there aren't any conflicts
if (this.commands.some((cmd) => cmd.keyName === slashCommand.keyName))
throw new Error(`A command with the name "${slashCommand.commandName}" (${slashCommand.keyName}) is already registered.`);
if (slashCommand.guildIDs &&
this.commands.some((cmd) => !!(cmd.type === slashCommand.type &&
cmd.commandName === slashCommand.commandName &&
cmd.guildIDs &&
cmd.guildIDs.some((gid) => slashCommand.guildIDs?.includes(gid)))))
throw new Error(`A command with the name "${slashCommand.commandName}" has a conflicting guild ID.`);
if (slashCommand.unknown && this.unknownCommand)
throw new Error('An unknown command is already registered.');
if (slashCommand.unknown)
this.unknownCommand = slashCommand;
else
this.commands.set(slashCommand.keyName, slashCommand);
this.emit('commandRegister', slashCommand);
this.emit('debug', `Registered command ${slashCommand.keyName}.`);
return slashCommand;
}
/**
* Registers multiple commands
* @param commands An array of Command instances or constructors
* @param ignoreInvalid Whether to skip over invalid objects without throwing an error
*/
registerCommands(commands, ignoreInvalid = false) {
if (!Array.isArray(commands))
throw new TypeError('Commands must be an Array.');
const registeredCommands = [];
for (const command of commands) {
try {
registeredCommands.push(this.registerCommand(command));
}
catch (e) {
if (ignoreInvalid) {
this.emit('warn', `Skipped an invalid command: ${e}`);
continue;
}
else
throw e;
}
}
return registeredCommands;
}
/**
* Registers all commands in a directory. The files must export a Command class constructor or instance.
* @param commandsPath The path to the command directory
* @param extensionsOrFilter An array of custom file extensions (with `.js` and `.cjs` already included) or a function that filters file names
* @example
* await creator.registerCommandsIn(require('path').join(__dirname, 'commands'));
*/
async registerCommandsIn(commandPath,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
extensionsOrFilter = []) {
throw new Error('registerCommandsIn() is not availble in this environment.');
}
/**
* Unregisters a command.
* @param command Command to unregister
*/
unregisterCommand(command) {
command.onUnload();
if (this.unknownCommand === command)
this.unknownCommand = undefined;
else
this.commands.delete(command.keyName);
this.emit('commandUnregister', command);
this.emit('debug', `Unregistered command ${command.keyName}.`);
}
/**
* Attaches a server to the creator.
* @param server The server to use
*/
withServer(server) {
if (this.server)
throw new Error('A server was already set in this creator.');
this.server = server;
if (this.server.isWebserver) {
if (!this.options.publicKey)
throw new Error('A public key is required to be set when using a webserver.');
this.server.createEndpoint(this.options.endpointPath, this._onRequest.bind(this));
}
else
this.server.handleInteraction((interaction) => this._onInteraction(interaction, null, false, null));
return this;
}
/** Starts the server, if one was defined. */
async startServer() {
if (!this.server)
throw new Error('No server was set in this creator.');
await this.server.listen(this.options.serverPort, this.options.serverHost);
this.emit('debug', 'Server started');
}
/**
* Sync all commands to Discord. This ensures that commands exist when handling them.
* <warn>This requires you to have your token set in the creator config.</warn>
*/
async syncCommands(opts) {
const options = Object.assign({
deleteCommands: true,
syncGuilds: true,
skipGuildErrors: true
}, opts);
let guildIDs = [];
// Collect guild IDs with specific commands
for (const [, command] of this.commands) {
if (command.guildIDs)
guildIDs = [...new Set([...guildIDs, ...command.guildIDs])];
}
await this.syncGlobalCommands(options.deleteCommands);
// Sync guild commands
if (options.syncGuilds) {
for (const guildID of guildIDs) {
try {
await this.syncCommandsIn(guildID, options.deleteCommands);
}
catch (e) {
if (options.skipGuildErrors) {
this.emit('warn', `An error occurred during guild sync (${guildID}): ${e.message}`);
}
else {
throw e;
}
}
}
}
this.emit('debug', 'Finished syncing commands');
}
/**
* Sync guild commands.
* <warn>This requires you to have your token set in the creator config.</warn>
* @param guildID The guild to sync
* @param deleteCommands Whether to delete command not found in the creator
*/
async syncCommandsIn(guildID, deleteCommands = true) {
const commands = await this.api.getCommands(guildID, true);
const handledCommands = [];
const updatePayload = [];
for (const applicationCommand of commands) {
const partialCommand = Object.assign({}, applicationCommand);
delete partialCommand.application_id;
delete partialCommand.guild_id;
delete partialCommand.id;
delete partialCommand.version;
const command = this.commands.find((command) => !!(command.guildIDs &&
command.guildIDs.includes(guildID) &&
command.commandName === partialCommand.name &&
command.type === partialCommand.type));
if (command) {
command.ids.set(guildID, applicationCommand.id);
this.emit('debug', `Found guild command "${applicationCommand.name}" (${applicationCommand.id}, type ${applicationCommand.type}, guild: ${guildID})`);
if (command.onLocaleUpdate)
await command.onLocaleUpdate();
updatePayload.push({
id: applicationCommand.id,
...command.toCommandJSON(false)
});
handledCommands.push(command.keyName);
}
else if (deleteCommands) {
this.emit('debug', `Removing guild command "${applicationCommand.name}" (${applicationCommand.id}, type ${applicationCommand.type}, guild: ${guildID})`);
}
else {
updatePayload.push(applicationCommand);
}
}
const commandsPayload = commands.map((cmd) => {
delete cmd.application_id;
delete cmd.guild_id;
delete cmd.version;
return cmd;
});
const unhandledCommands = this.commands.filter((command) => !!(command.guildIDs && command.guildIDs.includes(guildID) && !handledCommands.includes(command.keyName)));
for (const [, command] of unhandledCommands) {
this.emit('debug', `Creating guild command "${command.commandName}" (type ${command.type}, guild: ${guildID})`);
if (command.onLocaleUpdate)
await command.onLocaleUpdate();
updatePayload.push(command.toCommandJSON(false));
}
if (!(0, lodash_isequal_1.default)(updatePayload, commandsPayload)) {
// Set command IDs for permission syncing
const updatedCommands = await this.api.updateCommands(updatePayload, guildID);
const newCommands = updatedCommands.filter((newCommand) => !commands.find((command) => command.id === newCommand.id));
for (const newCommand of newCommands) {
const command = unhandledCommands.find((command) => command.commandName === newCommand.name);
if (command)
command.ids.set(guildID, newCommand.id);
}
}
}
/**
* Sync global commands.
* <warn>This requires you to have your token set in the creator config.</warn>
* @param deleteCommands Whether to delete command not found in the creator
*/
async syncGlobalCommands(deleteCommands = true) {
const commands = await this.api.getCommands(undefined, true);
const handledCommands = [];
const updatePayload = [];
for (const applicationCommand of commands) {
const partialCommand = Object.assign({}, applicationCommand);
const commandKey = `${partialCommand.type || constants_1.ApplicationCommandType.CHAT_INPUT}:global:${partialCommand.name}`;
delete partialCommand.application_id;
delete partialCommand.id;
delete partialCommand.version;
const command = this.commands.get(commandKey);
if (command) {
command.ids.set('global', applicationCommand.id);
this.emit('debug', `Found command "${applicationCommand.name}" (${applicationCommand.id}, type ${applicationCommand.type})`);
if (command.onLocaleUpdate)
await command.onLocaleUpdate();
updatePayload.push({
id: applicationCommand.id,
...command.toCommandJSON()
});
}
else if (deleteCommands) {
this.emit('debug', `Removing command "${applicationCommand.name}" (${applicationCommand.id}, type ${applicationCommand.type})`);
}
else {
updatePayload.push(applicationCommand);
}
handledCommands.push(commandKey);
}
const commandsPayload = commands.map((cmd) => {
delete cmd.application_id;
delete cmd.version;
return cmd;
});
const unhandledCommands = this.commands.filter((command) => !command.guildIDs && !handledCommands.includes(command.keyName));
for (const [, command] of unhandledCommands) {
this.emit('debug', `Creating command "${command.commandName}" (type ${command.type})`);
if (command.onLocaleUpdate)
await command.onLocaleUpdate();
updatePayload.push(command.toCommandJSON());
}
if (!(0, lodash_isequal_1.default)(updatePayload, commandsPayload)) {
const updatedCommands = await this.api.updateCommands(updatePayload);
const newCommands = updatedCommands.filter((newCommand) => !commands.find((command) => command.id === newCommand.id));
for (const newCommand of newCommands) {
const command = unhandledCommands.find((command) => command.commandName === newCommand.name);
if (command)
command.ids.set('global', newCommand.id);
}
}
}
/**
* Updates the command IDs internally in the creator.
* Use this if you make any changes to commands in the API.
* @param skipGuildErrors Whether to prevent throwing an error if the API failed to get guild commands
*/
async collectCommandIDs(skipGuildErrors = true) {
let guildIDs = [];
for (const [, command] of this.commands) {
if (command.guildIDs)
guildIDs = [...new Set([...guildIDs, ...command.guildIDs])];
}
const commands = await this.api.getCommands();
for (const applicationCommand of commands) {
const commandKey = `${applicationCommand.type}:global:${applicationCommand.name}`;
const command = this.commands.get(commandKey);
if (command)
command.ids.set('global', applicationCommand.id);
}
for (const guildID of guildIDs) {
try {
const commands = await this.api.getCommands(guildID);
for (const applicationCommand of commands) {
const command = this.commands.find((command) => !!(command.guildIDs &&
command.guildIDs.includes(guildID) &&
command.commandName === applicationCommand.name &&
command.type === applicationCommand.type));
if (command)
command.ids.set(guildID, applicationCommand.id);
}
}
catch (e) {
if (skipGuildErrors) {
this.emit('warn', `An error occurred during guild command ID collection (${guildID}): ${e}`);
}
else {
throw e;
}
}
}
}
/**
* Registers a global component callback. Note that this will have no expiration, and should be invoked by the returned name.
* @param custom_id The custom ID of the component to register
* @param callback The callback to use on interaction
*/
registerGlobalComponent(custom_id, callback) {
const newName = `global-${custom_id}`;
if (this._componentCallbacks.has(newName))
throw new Error(`A global component with the ID "${newName}" is already registered.`);
this._componentCallbacks.set(newName, {
callback,
expires: undefined,
onExpired: undefined
});
}
/**
* Unregisters a global component callback.
* @param custom_id The custom ID of the component to unregister
*/
unregisterGlobalComponent(custom_id) {
return this._componentCallbacks.delete(`global-${custom_id}`);
}
/**
* Registers a global modal callback. Note that this will have no expiration, and should be invoked by the returned name.
* @param custom_id The custom ID of the modal to register
* @param callback The callback to use on interaction
*/
registerGlobalModal(custom_id, callback) {
const newName = `global-${custom_id}`;
if (this._modalCallbacks.has(newName))
throw new Error(`A global model with the ID "${newName}" is already registered.`);
this._modalCallbacks.set(newName, {
callback,
expires: undefined,
onExpired: undefined
});
}
/**
* Unregisters a global modal callback.
* @param custom_id The custom ID of the component to unregister
*/
unregisterGlobalModal(custom_id) {
return this._modalCallbacks.delete(`global-${custom_id}`);
}
/**
* Cleans any awaiting component callbacks from command contexts.
*/
cleanRegisteredComponents() {
if (this._componentCallbacks.size)
for (const [key, callback] of this._componentCallbacks) {
if (callback.expires != null && callback.expires < Date.now()) {
if (callback.onExpired != null)
callback.onExpired();
this._componentCallbacks.delete(key);
}
}
if (this._modalCallbacks.size)
for (const [key, callback] of this._modalCallbacks) {
if (callback.expires != null && callback.expires < Date.now()) {
if (callback.onExpired != null)
callback.onExpired();
this._modalCallbacks.delete(key);
}
}
}
_getCommandFromInteraction(interaction) {
return 'guild_id' in interaction
? this.commands.find((command) => !!(command.guildIDs &&
command.guildIDs.includes(interaction.guild_id) &&
command.commandName === interaction.data.name &&
command.type === interaction.data.type)) || this.commands.get(`${interaction.data.type}:global:${interaction.data.name}`)
: this.commands.get(`${interaction.data.type}:global:${interaction.data.name}`);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async _verify(body, signature, timestamp) {
throw new Error(`${this.constructor.name} doesn't have a _verify() method.`);
}
async _onRequest(treq, respond, context) {
this.emit('debug', 'Got request');
this.emit('rawRequest', treq);
// Verify request
const signature = treq.headers['x-signature-ed25519'];
const timestamp = treq.headers['x-signature-timestamp'];
const verified = await this._verify(treq.rawBody || JSON.stringify(treq.body), signature, timestamp);
if (!verified) {
this.emit('debug', 'A request failed to be verified');
this.emit('unverifiedRequest', treq);
return void respond({
status: 401,
body: 'Invalid signature'
});
}
try {
await this._onInteraction(treq.body, respond, true, context);
}
catch (e) { }
}
async _onInteraction(interaction, respond, webserverMode, serverContext) {
this.emit('rawInteraction', interaction);
// User preferred POSTing callbacks
if (this.options.postCallbacks && respond && interaction.type !== constants_1.InteractionType.PING)
await respond({
status: 202
});
if (!respond || !webserverMode || (this.options.postCallbacks && interaction.type !== constants_1.InteractionType.PING))
respond = this._createGatewayRespond(interaction.id, interaction.token);
switch (interaction.type) {
case constants_1.InteractionType.PING: {
this.emit('debug', 'Ping received');
this.emit('ping', interaction.user);
return respond({
status: 200,
body: {
type: constants_1.InteractionResponseType.PONG
}
});
}
case constants_1.InteractionType.APPLICATION_COMMAND: {
if (this.options.handleCommandsManually) {
this.emit('commandInteraction', interaction, respond, webserverMode);
return;
}
const command = this._getCommandFromInteraction(interaction);
if (!command) {
this.emit('debug', `Unknown command: ${interaction.data.name} (${interaction.data.id}, ${'guild_id' in interaction ? `guild ${interaction.guild_id}` : `user ${interaction.user.id}`})`);
if (this.unknownCommand) {
const ctx = new commandContext_1.CommandContext(this, interaction, respond, webserverMode, this.unknownCommand.deferEphemeral, !this.options.disableTimeouts, serverContext);
return this._runCommand(this.unknownCommand, ctx);
}
else if (this.options.unknownCommandResponse)
return respond({
status: 200,
body: {
type: constants_1.InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: (0, util_1.oneLine) `
This command no longer exists.
This command should no longer show up in an hour if it has been deleted.
`,
flags: constants_1.MessageFlags.EPHEMERAL
}
}
});
else
return respond({
status: 400
});
}
else {
const ctx = new commandContext_1.CommandContext(this, interaction, respond, webserverMode, command.deferEphemeral, !this.options.disableTimeouts, serverContext);
// Ensure the user has permission to use the command
const hasPermission = command.hasPermission(ctx);
if (!hasPermission || typeof hasPermission === 'string') {
const data = { response: typeof hasPermission === 'string' ? hasPermission : undefined };
this.emit('commandBlock', command, ctx, 'permission', data);
return command.onBlock(ctx, 'permission', data);
}
// Throttle the command
const throttleResult = await command.throttle(ctx);
if (throttleResult) {
const data = { throttle: throttleResult, remaining: throttleResult.retryAfter };
this.emit('commandBlock', command, ctx, 'throttling', data);
return command.onBlock(ctx, 'throttling', data);
}
// Run the command
return this._runCommand(command, ctx);
}
}
case constants_1.InteractionType.MESSAGE_COMPONENT: {
this.emit('debug', `Component request received: ${interaction.data.custom_id}, (msg ${interaction.message.id}, ${'guild_id' in interaction ? `guild ${interaction.guild_id}` : `user ${interaction.user.id}`})`);
if (this._componentCallbacks.size || this.listenerCount('componentInteraction') > 0) {
const ctx = new componentContext_1.ComponentContext(this, interaction, respond, !this.options.disableTimeouts, serverContext);
this.emit('componentInteraction', ctx);
this.cleanRegisteredComponents();
const componentCallbackKey = `${ctx.message.id}-${ctx.customID}`;
const globalCallbackKey = `global-${ctx.customID}`;
const wildcardCallbackKey = `${ctx.message.id}-*`;
if (this._componentCallbacks.has(componentCallbackKey))
return this._componentCallbacks.get(componentCallbackKey).callback(ctx);
if (this._componentCallbacks.has(globalCallbackKey))
return this._componentCallbacks.get(globalCallbackKey).callback(ctx);
if (this._componentCallbacks.has(wildcardCallbackKey))
return this._componentCallbacks.get(wildcardCallbackKey).callback(ctx);
break;
}
else
return respond({
status: 200,
body: {
type: constants_1.InteractionResponseType.DEFERRED_UPDATE_MESSAGE
}
});
}
case constants_1.InteractionType.APPLICATION_COMMAND_AUTOCOMPLETE: {
const command = this._getCommandFromInteraction(interaction);
const ctx = new autocompleteContext_1.AutocompleteContext(this, interaction, respond, serverContext);
this.emit('autocompleteInteraction', ctx, command);
if (!command) {
this.emit('debug', `Unknown command autocomplete request: ${interaction.data.name} (${interaction.data.id}, ${'guild_id' in interaction ? `guild ${interaction.guild_id}` : `user ${interaction.user.id}`})`);
return respond({
status: 400
});
}
else {
try {
this.emit('debug', `Running autocomplete function: ${interaction.data.name} (${interaction.data.id}, ${'guild_id' in interaction ? `guild ${interaction.guild_id}` : `user ${interaction.user.id}`})`);
const retVal = await command.autocomplete(ctx);
if (Array.isArray(retVal) && !ctx.responded)
await ctx.sendResults(retVal);
return;
}
catch (err) {
return this.emit('error', err);
}
}
}
case constants_1.InteractionType.MODAL_SUBMIT: {
try {
const ctx = new modalInteractionContext_1.ModalInteractionContext(this, interaction, respond, !this.options.disableTimeouts, serverContext);
this.emit('modalInteraction', ctx);
this.cleanRegisteredComponents();
const modalCallbackKey = `${ctx.user.id}-${ctx.customID}`;
const globalCallbackKey = `global-${ctx.customID}`;
if (this._modalCallbacks.has(modalCallbackKey)) {
await this._modalCallbacks.get(modalCallbackKey).callback(ctx);
this._modalCallbacks.delete(modalCallbackKey);
}
else if (this._modalCallbacks.has(globalCallbackKey)) {
await this._modalCallbacks.get(globalCallbackKey).callback(ctx);
}
return;
}
catch (err) {
return this.emit('error', err);
}
}
default: {
// @ts-ignore
this.emit('debug', `Unknown interaction type received: ${interaction.type}`);
this.emit('unknownInteraction', interaction);
return respond({
status: 400
});
}
}
}
async _runCommand(command, ctx) {
try {
this.emit('debug', `Running command: ${ctx.data.data.name} (${ctx.data.data.id}, ${'guild_id' in ctx.data ? `guild ${ctx.data.guild_id}` : `user ${ctx.data.user.id}`})`);
const promise = command.run(ctx);
this.emit('commandRun', command, promise, ctx);
const retVal = await promise;
if (retVal)
return command.finalize(retVal, ctx);
}
catch (err) {
this.emit('commandError', command, err, ctx);
try {
return command.onError(err, ctx);
}
catch (secondErr) {
return this.emit('error', secondErr);
}
}
}
_createGatewayRespond(interactionID, token) {
return async (response) => this.api.interactionCallback(interactionID, token, response.body, response.files, true);
}
}
exports.BaseSlashCreator = BaseSlashCreator;