slash-create-modify
Version:
Create and sync Discord slash commands!
696 lines (695 loc) • 33.9 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Creator = exports.SlashCreator = void 0;
const eventemitter3_1 = __importDefault(require("eventemitter3"));
const util_1 = require("./util");
const constants_1 = require("./constants");
const requestHandler_1 = require("./util/requestHandler");
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 path_1 = __importDefault(require("path"));
const modalInteractionContext_1 = require("./structures/interfaces/modalInteractionContext");
/** The main class for using commands and interactions. */
class SlashCreator extends eventemitter3_1.default {
/** @param opts The options for the creator */
constructor(opts) {
// 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,
latencyThreshold: 30000,
ratelimiterOffset: 0,
requestTimeout: 15000,
maxSignatureTimestamp: 5000,
endpointPath: '/interactions',
serverPort: 8030,
serverHost: 'localhost'
}, opts);
this.allowedMentions = util_1.formatAllowedMentions(this.options.allowedMentions);
this.requestHandler = new requestHandler_1.RequestHandler(this);
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);
this.emit('debug', `Registered command ${slashCommand.keyName}.`);
return this;
}
/**
* 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.');
for (const command of commands) {
try {
this.registerCommand(command);
}
catch (e) {
if (ignoreInvalid) {
this.emit('warn', `Skipped an invalid command: ${e}`);
continue;
}
else
throw e;
}
}
return this;
}
/**
* 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 customExtensions An array of custom file extensions (`.js` and `.cjs` are already included)
* @example
* const path = require('path');
* creator.registerCommandsIn(path.join(__dirname, 'commands'));
*/
registerCommandsIn(commandPath, customExtensions = []) {
const extensions = ['.js', '.cjs', ...customExtensions];
const paths = util_1.getFiles(commandPath).filter((file) => extensions.includes(path_1.default.extname(file)));
const commands = [];
for (const filePath of paths) {
try {
commands.push(require(filePath));
}
catch (e) {
this.emit('error', new Error(`Failed to load command ${filePath}: ${e}`));
}
}
return this.registerCommands(commands, true);
}
/**
* Reregisters a command. (does not support changing name, or guild IDs)
* @param command New command
* @param oldCommand Old command
*/
reregisterCommand(command, oldCommand) {
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 reregister: ${command}`);
const slashCommand = command;
oldCommand.onUnload();
if (!slashCommand.unknown) {
if (slashCommand.commandName !== oldCommand.commandName)
throw new Error('Command name cannot change.');
if (!lodash_isequal_1.default(slashCommand.guildIDs, oldCommand.guildIDs))
throw new Error('Command guild IDs cannot change.');
this.commands.set(slashCommand.keyName, slashCommand);
}
else if (this.unknownCommand !== oldCommand) {
throw new Error('An unknown command is already registered.');
}
else {
this.unknownCommand = slashCommand;
}
this.emit('commandReregister', slashCommand, oldCommand);
this.emit('debug', `Reregistered command ${slashCommand.keyName}.`);
}
/**
* 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));
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>
*/
syncCommands(opts) {
this.syncCommandsAsync(opts)
.then(() => this.emit('synced'))
.catch((err) => this.emit('error', err));
return this;
}
/**
* Sync all commands to Discord asyncronously. This ensures that commands exist when handling them.
* <warn>This requires you to have your token set in the creator config.</warn>
*/
async syncCommandsAsync(opts) {
const options = Object.assign({
deleteCommands: true,
syncGuilds: true,
skipGuildErrors: true,
syncPermissions: 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
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');
if (options.syncPermissions)
await this.syncCommandPermissions();
}
/**
* 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 ? command.toCommandJSON(false) : command.commandJSON)
});
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 ? command.toCommandJSON(false) : command.commandJSON)
});
}
if (!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 ? command.toCommandJSON() : command.commandJSON)
});
}
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 ? command.toCommandJSON() : command.commandJSON)
});
}
if (!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);
}
}
}
/**
* Sync command permissions.
* <warn>This requires you to have your token set in the creator config AND have commands already synced previously.</warn>
* @deprecated Command permissions have been deprecated: https://link.snaz.in/sc-cpd
*/
async syncCommandPermissions() {
const guildPayloads = {};
for (const [, command] of this.commands) {
if (command.permissions) {
for (const guildID in command.permissions) {
const commandID = command.ids.get(guildID) || command.ids.get('global');
if (!commandID)
continue;
if (!(guildID in guildPayloads))
guildPayloads[guildID] = [];
guildPayloads[guildID].push({
id: commandID,
permissions: command.permissions[guildID]
});
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const guildID in guildPayloads) {
this.emit('warn', 'Syncing command permissions has been deprecated and will be removed in the future: https://link.snaz.in/sc-cpd');
// await this.api.bulkUpdateCommandPermissions(guildID, guildPayloads[guildID]);
}
}
/**
* 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}`);
}
/**
* 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}`);
}
async _onRequest(treq, respond) {
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'];
// Check if both signature and timestamp exists, and the timestamp isn't past due.
if (!signature ||
!timestamp ||
parseInt(timestamp) < (Date.now() - this.options.maxSignatureTimestamp) / 1000) {
this.emit('debug', 'A request failed to be verified due to a bad timestamp. If this error persists, consider increasing maxSignatureTimestamp');
this.emit('unverifiedRequest', treq);
return respond({
status: 401,
body: 'Invalid signature'
});
}
const verified = await util_1.verifyKey(JSON.stringify(treq.body), signature, timestamp, this.options.publicKey);
if (!verified) {
this.emit('debug', 'A request failed to be verified');
this.emit('unverifiedRequest', treq);
return respond({
status: 401,
body: 'Invalid signature'
});
}
try {
await this._onInteraction(treq.body, respond, true);
}
catch (e) { }
}
async _onInteraction(interaction, respond, webserverMode) {
this.emit('rawInteraction', interaction);
if (!respond || !webserverMode)
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);
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: 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.InteractionResponseFlags.EPHEMERAL
}
}
});
else
return respond({
status: 400
});
}
else {
const ctx = new commandContext_1.CommandContext(this, interaction, respond, webserverMode, command.deferEphemeral, !this.options.disableTimeouts);
// 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 throttle = command.throttle(ctx.user.id);
if (throttle && command.throttling && throttle.usages + 1 > command.throttling.usages) {
const remaining = (throttle.start + command.throttling.duration * 1000 - Date.now()) / 1000;
const data = { throttle, remaining };
this.emit('commandBlock', command, ctx, 'throttling', data);
return command.onBlock(ctx, 'throttling', data);
}
// Run the command
if (throttle)
throttle.usages++;
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);
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);
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 context = new modalInteractionContext_1.ModalInteractionContext(this, interaction, respond, !this.options.disableTimeouts);
this.emit('modalInteraction', context);
this.cleanRegisteredComponents();
const modalCallbackKey = `${context.user.id}-${context.customID}`;
if (this._modalCallbacks.has(modalCallbackKey)) {
this._modalCallbacks.get(modalCallbackKey).callback(context);
this._modalCallbacks.delete(modalCallbackKey);
return;
}
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) => {
await this.api.interactionCallback(interactionID, token, response.body, response.files);
};
}
}
exports.SlashCreator = SlashCreator;
exports.Creator = SlashCreator;