UNPKG

commandbot

Version:

A framework that helps you create your own Discord bot easier.

553 lines (552 loc) 23.4 kB
import axios from "axios"; import { Guild, Interaction, Message } from "discord.js"; import { InputParameter, ObjectID, TargetID } from "./Parameter.js"; import { CommandNotFound } from "../errors.js"; import { applicationState } from "../state.js"; import { ChatCommand } from "../commands/ChatCommand.js"; import { ContextMenuCommand } from "../commands/ContextMenuCommand.js"; import { CommandRegExps } from "../commands/commandsTypes.js"; import { SubCommand } from "../commands/SubCommand.js"; import { SubCommandGroup } from "../commands/SubCommandGroup.js"; import { HelpMessage } from "../commands/Help.js"; import { PrefixManager } from "./PrefixManager.js"; import { InputManager } from "./InputManager.js"; /** * Object that stores the registered commands and is responsible for data exchanging with the Discord API * @class */ export class CommandManager { /** * List of commands registered in the manager * @type {Array<Command>} * @private * @readonly */ _commands = []; /** * Cache of Discord API commands data * @type {Map<string, Map<string, RegisteredCommandObject>>} * @private * @readonly */ _registerCache = new Map(); _globalEntryName = "global"; /** * Client connected to this manager * @type {Client} * @public * @readonly */ client; /** * Help command associated with this manager * @type {?HelpMessage} * @public * @readonly */ help; /** * A manager holding all guild-specific prefixes and a global prefix * @type {string} * @public * @readonly */ prefix; /** * A string used to split all incoming input data from Discord messages * @type {string} * @public * @readonly */ argumentSeparator; /** * A string used to separate subcommand groups and subcommands * @type {string} * @public * @readonly */ commandSeparator; /** * Discord API URL * @type {string} * @public * @static * @readonly */ static baseApiUrl = "https://discord.com/api/v8"; /** * * @constructor * @param {Bot} client - client that this manager belongs to * @param {HelpMessageParams} helpMsg - parameters defining appearance of the help message * @param {?string} [prefix] - prefix used to respond to message interactions * @param {?string} [argSep=','] - a string used to split all incoming input data from Discord messages * @param {?string} [cmdSep='/'] - a string used to separate subcommand groups and subcommands */ constructor(client, helpMsg, prefix, argSep, cmdSep) { if ((argSep && !CommandRegExps.separator.test(argSep)) || (cmdSep && !CommandRegExps.separator.test(cmdSep))) { throw new Error("Incorrect separators"); } this.client = client; this.prefix = new PrefixManager(this, prefix); this.argumentSeparator = argSep || ","; this.commandSeparator = cmdSep || "/"; if (this.commandSeparator === this.argumentSeparator) { throw new Error("Command separator and argument separator have the same value"); } if (helpMsg.enabled === true) { this.help = new HelpMessage(this, helpMsg); this._commands.push(this.help); } } /** * Discord API commands cache * @type {Map<string, Map<string, RegisteredCommandObject>>} */ get cache() { return this._registerCache; } /** * Number of commands registered in this manager * @type {number} */ get commandsCount() { return this._commands.length; } /** * Creates and registers command in the manager based on the given options * @param {T} type - a type of command that will be created and added to this manager * @param {CommandInit<T>} options - an object containing all properties required to create this type of command * @returns {Commands<T>} A computed command object that inherits from {@link Command} * @public * @remarks All commands have to be added to the instance **before starting the bot**. Adding commands while the bot is running is not possible and can cause issues. * * Command types * - [CHAT](https://grz4na.github.io/commandbot-docs/interfaces/ChatCommandInit.html) - message interactions using command prefixes or slash commands * - [USER](https://grz4na.github.io/commandbot-docs/interfaces/ContextMenuCommandInit.html) - right-click context menu interactions on users * - [MESSAGE](https://grz4na.github.io/commandbot-docs/interfaces/ContextMenuCommandInit.html) - right-click context menu interactions on messages */ add(type, options) { const command = type === "CHAT" ? new ChatCommand(this, options) : type === "CONTEXT" ? new ContextMenuCommand(this, options) : null; if (!command) { throw new TypeError("Incorrect command type"); } if (applicationState.running) { console.warn(`[❌ ERROR] Cannot add command "${command.name}" while the application is running.`); return command; } if (command instanceof SubCommand || command instanceof SubCommandGroup) { throw new Error("Registering subcommands and subcommand groups through the 'add' method is not allowed. Use NestedCommand.append or SubCommandGroup.append to register."); } this._commands.push(command); return command; } /** * Get command registered in this manager * @param {string} q - command name or alias * @param {?APICommandType} [t] - type of command you want to get from this manager (if *undefined* searches in all registered commands) * @returns {?Command} A command object * @public */ get(q, t) { switch (t) { case "CHAT": const cmdList = this.list(t); return (cmdList.find((c) => c.name === q || (c.aliases && c.aliases.length > 0 && c.aliases.find((a) => a === q))) ?? cmdList .filter((c) => c.hasSubCommands) .map((c) => c.children.map((ch) => { if (ch instanceof SubCommand) return ch; else return ch.children; })) .flat(2) .find((c) => c.aliases?.find((a) => a === q)) ?? null); case "NESTED": return (this.list(t).find((c) => c.name === q) || null); case "CONTEXT": return this.list(t).find((c) => c.name === q) || null; default: return this.list().find((c) => c.name === q) || null; } } /** * Fetches command object from the Discord API * @param {string} id - Discord command ID * @param {?Guild | string} [guild] - ID of guild that this command belongs to * @param {?boolean} [noCache=false] - whether not to use cached data * @returns {Promise<RegisteredCommandObject>} Discord command object * @public * @async */ async getApi(id, guild, noCache) { const guildId = guild instanceof Guild ? guild.id : guild; if (!noCache) { const rqC = this.getCache(id, guildId); if (rqC) return rqC; } let rq; if (guildId) { rq = await axios.get(`${CommandManager.baseApiUrl}/applications/${this.client.applicationId}/guilds/${guildId}/commands/${id}`, { headers: { Authorization: `Bot ${this.client.token}` }, }); } else { rq = await axios.get(`${CommandManager.baseApiUrl}/applications/${this.client.applicationId}/commands/${id}`, { headers: { Authorization: `Bot ${this.client.token}` }, }); } if (rq.status === 200) { this.updateCache(rq.data); return rq.data; } else { throw new Error(`HTTP request failed with code ${rq.status}: ${rq.statusText}`); } } /** * Fetches command ID by name from the Discord APi * @param {string} name - name of the command * @param {string} type - command type you want to get ID for * @param {?string} [guild] - ID of guild that this command belongs to * @returns {string} Command ID from the Discord API * @public * @async */ async getIdApi(name, type, guild) { let map = await this.listApi(guild); let result = null; map?.forEach((c) => { const typeC = c.type === 1 ? "CHAT_INPUT" : c.type === 2 ? "USER" : "MESSAGE"; if (c.name === name && typeC === type) { result = c.id; } }); return result; } list(f) { switch (f) { case "CHAT": return Object.freeze([...this._commands.filter((c) => c.type === "CHAT")]); case "CONTEXT": return Object.freeze([...this._commands.filter((c) => c.type === "CONTEXT")]); default: return Object.freeze([...this._commands]); } } /** * Lists commands registered in the Discord API * @param {Guild | string} [g] - Guild object or ID * @returns {Promise<Map<string, RegisteredCommandObject>>} List of commands from Discord API * @public * @async */ async listApi(g) { const guildId = g instanceof Guild ? g.id : g; let rq; if (guildId) { rq = await axios.get(`${CommandManager.baseApiUrl}/applications/${this.client.applicationId}/guilds/${guildId}/commands`, { headers: { Authorization: `Bot ${this.client.token}` }, }); } else { rq = await axios.get(`${CommandManager.baseApiUrl}/applications/${this.client.applicationId}/commands`, { headers: { Authorization: `Bot ${this.client.token}` }, }); } if (rq.status === 200) { this.updateCache(rq.data, guildId); return this.arrayToMap(rq.data); } else { throw new Error(`HTTP request failed with code ${rq.status}: ${rq.statusText}`); } } /** * Process an interaction * @param {Interaction | Message} i - interaction object to fetch a command from * @returns {?InputManager} An InputManager containing all input data (command, arguments, target etc.) * @public */ fetch(i) { const prefix = this.prefix.get(i.guild || undefined); if (i instanceof Interaction) { if (i.isCommand()) { const cmd = this.get(i.commandName, "CHAT"); if (cmd instanceof ChatCommand) { if (cmd.hasSubCommands) { const subCmd = cmd.fetchSubcommand([...i.options.data], i); if (subCmd) return subCmd; } return new InputManager(cmd, i, cmd.parameters.map((p, index) => { if (p.type === "user" || p.type === "role" || p.type === "channel" || p.type === "mentionable") { return new InputParameter(p, new ObjectID(i.options.data[index]?.value?.toString() ?? "", p.type, i.guild ?? undefined)); } else { return new InputParameter(p, i.options.data[index]?.value ?? null); } })); } else { throw new CommandNotFound(i.commandName); } } else if (i.isContextMenu()) { const cmd = this.get(i.commandName, "CONTEXT"); if (cmd) { const target = new TargetID(i.targetId, i.targetType, i); return new InputManager(cmd, i, [], target); } else { throw new CommandNotFound(i.commandName); } } else { return null; } } else if (prefix && i instanceof Message) { if (i.content.startsWith(prefix)) { if (i.content === prefix) return null; const cmdName = i.content.replace(prefix, "").split(" ")[0].split(this.commandSeparator)[0]; const cmd = this.get(cmdName, "CHAT"); if (cmd instanceof ChatCommand) { const argsRaw = i.content .replace(`${prefix}${cmdName}`, "") .split(this.argumentSeparator) .map((a) => { if (a.startsWith(" ")) { return a.replace(" ", ""); } else { return a; } }); if (cmd.hasSubCommands) { const nesting = i.content.split(" ")[0].replace(`${prefix}${cmdName}${this.commandSeparator}`, "").split(this.commandSeparator); const subCmd = cmd.getSubcommand(nesting[1] ? nesting[1] : nesting[0], nesting[1] ? nesting[0] : undefined); if (subCmd) { const subArgsRaw = i.content .replace(`${prefix}${cmdName}${this.commandSeparator}${nesting.join(this.commandSeparator)}`, "") .split(this.argumentSeparator) .map((a) => { if (a.startsWith(" ")) { return a.replace(" ", ""); } else { return a; } }); return new InputManager(subCmd, i, subCmd.parameters.map((p, index) => { if (p.type === "user" || p.type === "role" || p.type === "channel" || p.type === "mentionable") { return new InputParameter(p, new ObjectID(subArgsRaw[index] ?? "", p.type, i.guild ?? undefined)); } else { return new InputParameter(p, subArgsRaw[index] ?? null); } })); } } return new InputManager(cmd, i, cmd.parameters.map((p, index) => { if (p.type === "user" || p.type === "role" || p.type === "channel" || p.type === "mentionable") { return new InputParameter(p, new ObjectID(argsRaw[index], p.type, i.guild ?? undefined)); } else { return new InputParameter(p, argsRaw[index]); } })); } else if (cmd instanceof SubCommand) { const subArgsRaw = i.content .replace(`${prefix}${cmdName}`, "") .split(this.argumentSeparator) .map((a) => { if (a.startsWith(" ")) { return a.replace(" ", ""); } else { return a; } }); return new InputManager(cmd, i, cmd.parameters.map((p, index) => { if (p.type === "user" || p.type === "role" || p.type === "channel" || p.type === "mentionable") { return new InputParameter(p, new ObjectID(subArgsRaw[index] ?? "", p.type, i.guild ?? undefined)); } else { return new InputParameter(p, subArgsRaw[index] ?? null); } })); } else { throw new CommandNotFound(cmdName); } } else { return null; } } else { return null; } } /** * Register all commands in this manager in the Discord API * @returns {Promise<void>} * @public * @async */ async register() { const globalCommands = this._commands .filter((c) => { if (c.isBaseCommandType("GUILD") && (!Array.isArray(c.guilds) || c.guilds.length === 0)) { if (c.isCommandType("CHAT") && c.slash === false) { return false; } else { return true; } } }) .map((c) => c.toObject()); const guildCommands = new Map(); this._commands .filter((c) => c.isBaseCommandType("GUILD") && Array.isArray(c.guilds) && c.guilds.length > 0) .map((c) => { c.isBaseCommandType("GUILD") && c.guilds?.map((gId) => { if (!this.client.client.guilds.cache.get(gId)) { throw new Error(`"${gId}" is not a valid ID for this client.`); } const existingEntry = guildCommands.get(gId); if (!existingEntry) { guildCommands.set(gId, [c.toObject()]); } else { guildCommands.set(gId, [...existingEntry, c.toObject()]); } }); }); await axios .put(`${CommandManager.baseApiUrl}/applications/${this.client.applicationId}/commands`, globalCommands, { headers: { Authorization: `Bot ${this.client.token}` }, }) .then((r) => { if (r.status === 429) { console.error("[❌ ERROR] Failed to register application commands. You are being rate limited."); } }) .catch((e) => console.error(e)); await guildCommands.forEach(async (g, k) => { await axios .put(`${CommandManager.baseApiUrl}/applications/${this.client.applicationId}/guilds/${k}/commands`, g, { headers: { Authorization: `Bot ${this.client.token}` }, }) .then((r) => { if (r.status === 429) { console.error(`[❌ ERROR] Failed to register application commands for guild ${k}. You are being rate limited.`); } }) .catch((e) => console.error(e)); }); } /** * Set permissions using Discord Permissions API * @param {string} id - command ID * @param {CommandPermission[]} permissions - permissions to set * @param {Guild | string} [g] - Guild ID or object (if command is in a guild) * @returns {Promise<void>} * @public * @async * @experimental This functionality hasn't been polished and fully tested yet. Using it might lead to errors and application crashes. */ async setPermissionsApi(id, permissions, g) { if (typeof g === "string" && !this.client.client.guilds.cache.get(g)) throw new Error(`${g} is not a valid guild id`); const response = await axios.put(`${CommandManager.baseApiUrl}/applications/${this.client.applicationId}/${g ? (g instanceof Guild ? `guilds/${g.id}` : g) : ""}commands/${id}/permissions`, { permissions: permissions, }, { headers: { Authorization: `Bot ${this.client.token}`, }, }); if (response.status !== 200) { throw new Error(`HTTP request failed with code ${response.status}: ${response.statusText}`); } } /** * Get permissions from Discord Permissions API for a specified command * @param {string} id - command ID * @param {Guild | string} [g] - Guild ID or object (if command is in a guild) * @public * @async * @experimental This functionality hasn't been polished and fully tested yet. Using it might lead to errors and application crashes. */ async getPermissionsApi(id, g) { if (typeof g === "string" && !this.client.client.guilds.cache.get(g)) throw new Error(`${g} is not a valid guild id`); const response = await axios.get(`${CommandManager.baseApiUrl}/applications/${this.client.applicationId}/${g ? (g instanceof Guild ? `guilds/${g.id}` : g) : ""}commands/${id}/permissions`, { headers: { Authorization: `Bot ${this.client.token}`, }, }); if (response.status !== 200) { throw new Error(`HTTP request failed with code ${response.status}: ${response.statusText}`); } return response.data; } /** * * @param {Array<RegisteredCommandObject>} commands - list of commands to cache * @param {?string} guildId - guild ID * @returns {void} * @private */ updateCache(commands, guildId) { if (Array.isArray(commands)) { this._registerCache.set(guildId || this._globalEntryName, this.arrayToMap(commands)); return; } else { this._registerCache.get(guildId || this._globalEntryName)?.set(commands.id, commands); } } /** * Retrieves cache from the manager * @param {string} q * @param {?string} guildId * @returns {?RegisteredCommandObject} */ getCache(q, guildId) { return this._registerCache.get(guildId || this._globalEntryName)?.get(q) || null; } /** * Performs internal data type conversions * @param {Array<RegisteredCommandObject>} a * @returns {Map<string, RegisteredCommandObject>} */ arrayToMap(a) { const map = new Map(); a.map((rc) => { map.set(rc.id, rc); }); return map; } /** * @param {any} c - object to check * @returns {boolean} Whether this object is a {@link Command} object * @public * @static */ static isCommand(c) { return "name" in c && "type" in c && "default_permission" in c && (c.type === "CHAT" || c.type === "CONTEXT"); } }