registerPlugin({ name: "Command Library", description: "Library to handle and manage commands", version: "1.4.4", author: "Multivitamin ", autorun: true, backends: ["ts3", "discord"], vars: [{ name: "NOT_FOUND_MESSAGE", title: "Send a message if no command has been found?", type: "select", options: ["YES", "NO"], default: "1" }, { name: "DEBUGLEVEL", title: "Debug Messages (default is INFO)", type: "select", options: ["ERROR", "WARNING", "INFO", "VERBOSE"], default: "2" }] }, (_, { DEBUGLEVEL, NOT_FOUND_MESSAGE }, { version }) => { const engine = require("engine") const event = require("event") const backend = require("backend") const format = require("format") /** * @param {number} level * @return {(mode: number) => (...args: any[]) => void} * @private */ function DEBUG(level) { /** * @param {number} mode the loglevel to log * @param {number[]} args data to log * @private */ const logger = (mode, ...args) => { if (mode > level) return engine.log(...args) } return mode => logger.bind(null, mode) } DEBUG.VERBOSE = 3 DEBUG.INFO = 2 DEBUG.WARNING = 1 DEBUG.ERROR = 0 /** @private */ const debug = DEBUG(parseInt(DEBUGLEVEL, 10)) //////////////////////////////////////////////////////////// //// TYPES //// //////////////////////////////////////////////////////////// /** * callback for the command event * @callback createArgumentHandler * @param {ArgType} arg * @returns {Argument} */ /** * @typedef ArgType * @type {object} * @property {StringArgument} string * @property {NumberArgument} number * @property {ClientArgument} client * @property {RestArgument} rest * @property {GroupArgument} or * @property {GroupArgument} and */ // eslint-disable-next-line no-unused-vars const ArgType = {} /** * @ignore * @typedef CommanderTextMessage * @type {object} * @property {(msg: string) => void} reply function to reply back * @property {Client} client the client which invoked the command * @property {Record} arguments arguments from the command * @property {Message} raw raw message * @property {DiscordMessage} [message] */ /** * @ignore * @typedef MessageEvent * @type {object} * @property {Client} client * @property {Channel} channel * @property {string} text * @property {number} mode * @property {DiscordMessage} [message] */ /** * callback for the command event * @callback execHandler * @param {Client} invoker * @param {Record} args * @param {(msg: string) => void} reply * @param {MessageEvent} event */ /** * callback for the command event * @callback permissionHandler * @param {Client} invoker */ /** * @ignore * @typedef ThrottleInterface * @property {number} points * @property {number} next * @property {number} timeout */ //////////////////////////////////////////////////////////// //// EXCEPTIONS //// //////////////////////////////////////////////////////////// /** * class representing a ThrottleError * @private */ class ThrottleError extends Error { /** @param {string} err */ constructor(err) { super(err) } } /** * class representing a TooManyArguments * @private */ class TooManyArgumentsError extends Error { /** * @param {string} err * @param {ParseError|undefined} parseError */ constructor(err, parseError) { super(err) this.parseError = parseError } } /** * class representing a ParseError * gets thrown when an Argument has not been parsed successful * @private */ class ParseError extends Error { /** * @param {string} err * @param {Argument} argument */ constructor(err, argument) { super(err) this.argument = argument } } /** * class representing a SubCommandNotFoundError * @private */ class CommandNotFoundError extends Error { /** @param {string} err */ constructor(err) { super(err) } } /** * class representing a PermissionError * @private */ class PermissionError extends Error { /** @param {string} err */ constructor(err) { super(err) } } //////////////////////////////////////////////////////////// //// ARGUMENTS //// //////////////////////////////////////////////////////////// /** * @name Argument */ class Argument { constructor() { /** * @type {boolean} * @private */ this._optional = false /** * @type {string} * @private */ this._name = "_" /** * @type {string} * @private */ this._display = "_" /** * @type {boolean} * @private */ this._displayDefault = true /** * @type {any} * @private */ this._default = undefined } /** * @abstract * @param {string} args * @returns {any[]} */ validate(args) { throw new Error("not implemented") } /** * Sets an Argument as optional * if the argument has not been parsed successful it will use the first argument which has been given inside this method * @param {any} [fallback] the default value which should be set if this parameter has not been found * @param {boolean} [displayDefault] wether it should display the default value when called with the #getUsage method */ optional(fallback, displayDefault = true) { this._displayDefault = displayDefault this._default = fallback this._optional = true return this } /** retrieves the default value if it had been set */ getDefault() { return this._default } /** checks if the Argument has a default value */ hasDefault() { return this._default !== undefined } /** gets the manual of a command */ getManual() { if (this.isOptional()) { if (this._displayDefault && this.hasDefault()) { return `[${this._display}=${this.getDefault()}]` } else { return `[${this._display}]` } } else { return `<${this._display}>` } } /** checks if the Argument is optional */ isOptional() { return this._optional } /** * Sets a name for the argument to identify it later when the command gets dispatched * This name will be used when passing the parsed argument to the exec function * @param {string} name sets the name of the argument * @param {string} [display] sets a beautified display name which will be used when the getManual command gets executed, if none given it will use the first parameter as display value */ setName(name, display) { this._display = display === undefined ? name : display if (typeof name !== "string") throw new Error("Argument of setName needs to be a string") if (name.length < 1) throw new Error("Argument of setName needs to be at least 1 char long") if (!name.match(/^[a-z0-9_]+$/i)) throw new Error("Argument of setName should contain only chars A-z, 0-9 and _") this._name = name return this } /** * Retrieves the name of the Argument * @returns {string} retrieves the arguments name */ getName() { return this._name } /** * creates new object with argument options * @returns {ArgType} */ static createArgumentLayer() { return { string: new StringArgument(), number: new NumberArgument(), client: new ClientArgument(), rest: new RestArgument(), or: new GroupArgument("or"), and: new GroupArgument("and") } } } /** * @name StringArgument */ class StringArgument extends Argument { constructor() { super() /** * @type {?RegExp} * @private */ this._regex = null /** * @type {?number} * @private */ this._maxlen = null /** * @type {?number} * @private */ this._minlen = null /** * @type {?string[]} * @private */ this._whitelist = null /** * @type {boolean} * @private */ this._uppercase = false /** * @type {boolean} * @private */ this._lowercase = false } /** * Validates the given String to the StringArgument * @param {string} args the remaining args */ validate(args) { const argArray = args.split(" ") const str = argArray.shift() return this._validate(str||"", argArray.join(" ")) } /** * Validates the given string to the StringArgument params * @protected * @param {string} arg string argument that should be parsed * @param {string[]} rest the remaining args */ _validate(arg, ...rest) { if (this._uppercase) arg = arg.toUpperCase() if (this._lowercase) arg = arg.toLowerCase() if (this._minlen !== null && this._minlen > arg.length) throw new ParseError(`String length not greater or equal! Expected at least ${this._minlen}, but got ${arg.length}`, this) if (this._maxlen !== null && this._maxlen < arg.length) throw new ParseError(`String length not less or equal! Maximum ${this._maxlen} chars allowed, but got ${arg.length}`, this) if (this._whitelist !== null && !this._whitelist.includes(arg)) throw new ParseError(`Invalid Input for ${arg}. Allowed words: ${this._whitelist.join(", ")}`, this) if (this._regex !== null && !this._regex.test(arg)) throw new ParseError(`Regex missmatch, the input '${arg}' did not match the expression ${this._regex.toString()}`, this) return [arg, ...rest] } /** * Matches a regular expression pattern * @param {RegExp} regex the regex which should be validated */ match(regex) { this._regex = regex return this } /** * Sets the maximum Length of the String * @param {number} len the maximum length of the argument */ max(len) { this._maxlen = len return this } /** * Sets the minimum Length of the String * @param {number} len the minimum length of the argument */ min(len) { this._minlen = len return this } /** converts the input to an upper case string */ forceUpperCase() { this._lowercase = false this._uppercase = true return this } /** converts the input to a lower case string */ forceLowerCase() { this._lowercase = true this._uppercase = false return this } /** * creates a list of available whitelisted words * @param {string[]} words array of whitelisted words */ whitelist(words) { if (!Array.isArray(this._whitelist)) this._whitelist = [] this._whitelist.push(...words) return this } } /** * @name RestArgument */ class RestArgument extends StringArgument { /** * Validates the given String to the RestArgument * @param {string} args the remaining args */ validate(args) { return super._validate(args, "") } } /** * @name NumberArgument */ class NumberArgument extends Argument { constructor() { super() /** * @type {?number} * @private */ this._min = null /** * @type {?number} * @private */ this._max = null /** * @type {boolean} * @private */ this._int = false /** * @type {boolean} * @private */ this._forcePositive = false /** * @type {boolean} * @private */ this._forceNegative = false } /** * Validates the given Number to the Object * @param {string} args the remaining args */ validate(args) { const argArray = args.split(" ") const arg = argArray.shift()|| "" const num = parseFloat(arg) if (!(/^-?\d+(\.\d+)?$/).test(arg) || isNaN(num)) throw new ParseError(`"${arg}" is not a valid number`, this) if (this._min !== null && this._min > num) throw new ParseError(`Number not greater or equal! Expected at least ${this._min}, but got ${num}`, this) if (this._max !== null && this._max < num) throw new ParseError(`Number not less or equal! Expected at least ${this._max}, but got ${num}`, this) if (this._int && num % 1 !== 0) throw new ParseError(`Given Number is not an Integer! (${num})`, this) if (this._forcePositive && num <= 0) throw new ParseError(`Given Number is not Positive! (${num})`, this) if (this._forceNegative && num >= 0) throw new ParseError(`Given Number is not Negative! (${num})`, this) return [num, argArray.join(" ")] } /** * specifies the minimum value * @param {number} min the minimum length of the argument */ min(min) { this._min = min return this } /** * specifies the maximum value * @param {number} max the maximum length of the argument */ max(max) { this._max = max return this } /** specifies that the Number must be an integer (no floating point) */ integer() { this._int = true return this } /** specifies that the Number must be a positive Number */ positive() { this._forcePositive = true this._forceNegative = false return this } /** specifies that the Number must be a negative Number */ negative() { this._forcePositive = false this._forceNegative = true return this } } /** * Class representing a ClientArgument * this Argument is capable to parse a Client UID or a simple UID * inside the exec function it will resolve the found uid * @name ClientArgument */ class ClientArgument extends Argument { /** * Validates and tries to parse the Client from the given input string * @param {string} args the input from where the client gets extracted */ validate(args) { switch (engine.getBackend()) { case "ts3": return this._validateTS3(args) case "discord": return this._validateDiscord(args) default: throw new Error(`Unknown Backend ${engine.getBackend()}`) } } /** * Tries to validate a TeamSpeak Client URL or UID * @param {string} args the input from where the client gets extracted * @private */ _validateTS3(args) { const match = args.match(/^(\[URL=client:\/\/\d*\/(?[/+a-z0-9]{27}=)~.*\].*\[\/URL\]|(?[/+a-z0-9]{27}=)) *(?.*)$/i) if (!match || !match.groups) throw new ParseError("Client not found!", this) return [match.groups.url_uid || match.groups.uid, match.groups.rest] } /** * Tries to validate a Discord Client Name or ID * @param {string} args the input from where the client gets extracted * @private */ _validateDiscord(args) { const match = args.match(/^(<@(?\d{18})>|@(?.*?)#\d{4}) *(?.*)$/i) if (!match || !match.groups) throw new ParseError("Client not found!", this) const { id, name, rest } = match.groups if (id) { return [id, rest] } else if (name) { const client = backend.getClientByName(name) if (!client) throw new ParseError("Client not found!", this) return [client.uid().split("/")[1], rest] } else { throw new ParseError("Client not found!", this) } } } /** * @name GroupArgument */ class GroupArgument extends Argument { /** * @param {"or"|"and"} type */ constructor(type) { super() /** * @type {"or"|"and"} * @private */ this._type = type /** * @type {Argument[]} * @private */ this._arguments = [] } /** * Validates the given String to the GroupArgument * @param {string} args the remaining args */ validate(args) { switch (this._type) { case "or": return this._validateOr(args) case "and": return this._validateAnd(args) default: throw new Error(`got invalid group type '${this._type}'`) } } /** * Validates the given string to the "or" of the GroupArgument * @param {string} args the remaining args * @private */ _validateOr(args) { /** * @type {Error[]} * @private */ const errors = [] /** * @type {Record} * @private */ const resolved = {} const valid = this._arguments.some(arg => { try { const result = arg.validate(args) resolved[arg.getName()] = result[0] return (args = result[1].trim(), true) } catch (e) { errors.push(e) return false } }) if (!valid) throw new ParseError(`No valid match found`, this) return [resolved, args] } /** * Validates the given string to the "and" of the GroupArgument * @param {string} args the remaining args * @private */ _validateAnd(args) { /** * @type {Record} * @private */ const resolved = {} /** * @type {?Error} * @private */ let error = null this._arguments.some(arg => { try { const result = arg.validate(args) resolved[arg.getName()] = result[0] return (args = result[1].trim(), false) } catch (e) { error = e return true } }) if (error !== null) throw error return [resolved, args] } /** * adds an argument to the command * @param {createArgumentHandler|Argument} arg an argument to add */ addArgument(arg) { if (typeof arg === "function") arg = arg(Argument.createArgumentLayer()) if (!(arg instanceof Argument)) throw new Error(`Typeof arg should be function or instance of Argument but got ${arg}`) this._arguments.push(arg) return this } } //////////////////////////////////////////////////////////// //// Throttle //// //////////////////////////////////////////////////////////// /** * @name Throttle */ class Throttle { constructor() { /** * @type {Record} * @private */ this._throttled = {} /** * @type {number} * @private */ this._penalty = 1 /** * @type {number} * @private */ this._initial = 1 /** * @type {number} * @private */ this._restore = 1 /** * @type {number} * @private */ this._tickrate = 1000 } /* clears all timers */ stop() { Object.values(this._throttled).forEach(({ timeout }) => clearTimeout(timeout)) return this } /** * Defines how fast points will get restored * @param {number} duration time in ms how fast points should get restored */ tickRate(duration) { this._tickrate = duration return this } /** * The amount of points a command request costs * @param {number} amount the amount of points that should be reduduced */ penaltyPerCommand(amount) { this._penalty = amount return this } /** * The Amount of Points that should get restored per tick * @param {number} amount the amount that should get restored */ restorePerTick(amount) { this._restore = amount return this } /** * Sets the initial Points a user has at beginning * @param {number} initial the Initial amount of Points a user has */ initialPoints(initial) { this._initial = initial return this } /** * Reduces the given points for a Command for the given Client * @param {Client} client the client which points should be removed */ throttle(client) { this._reducePoints(client.uid()) return this.isThrottled(client) } /** * Restores points from the given id * @param {string} id the identifier for which the points should be stored * @private */ _restorePoints(id) { const throttle = this._throttled[id] if (throttle === undefined) return throttle.points += this._restore if (throttle.points >= this._initial) { Reflect.deleteProperty(this._throttled, id) } else { this._refreshTimeout(id) } } /** * Resets the timeout counter for a stored id * @param {string} id the identifier which should be added * @private */ _refreshTimeout(id) { if (this._throttled[id] === undefined) return clearTimeout(this._throttled[id].timeout) // @ts-ignore this._throttled[id].timeout = setTimeout(this._restorePoints.bind(this, id), this._tickrate) this._throttled[id].next = Date.now() + this._tickrate } /** * Removes points from an id * @param {string} id the identifier which should be added * @private */ _reducePoints(id) { const throttle = this._createIdIfNotExists(id) throttle.points -= this._penalty this._refreshTimeout(id) } /** * creates the identifier in the throttled object * @param {string} id the identifier which should be added * @private */ _createIdIfNotExists(id) { if (Object.keys(this._throttled).includes(id)) return this._throttled[id] this._throttled[id] = { points: this._initial, next: 0, timeout: 0 } return this._throttled[id] } /** * Checks if the given Client is affected by throttle limitations * @param {Client} client the TeamSpeak Client which should get checked */ isThrottled(client) { const throttle = this._throttled[client.uid()] if (throttle === undefined) return false return throttle.points <= 0 } /** * retrieves the time in milliseconds until a client can send his next command * @param {Client} client the client which should be checked * @returns returns the time a client is throttled in ms */ timeTillNextCommand(client) { if (this._throttled[client.uid()] === undefined) return 0 return this._throttled[client.uid()].next - Date.now() } } //////////////////////////////////////////////////////////// //// COMMAND //// //////////////////////////////////////////////////////////// /** * @name BaseCommand */ class BaseCommand { /** * @param {string} cmd * @param {Collector} collector */ constructor(cmd, collector) { /** * @type {Collector} * @protected */ this._collector = collector /** * @type {permissionHandler[]} * @private */ this._permissionHandler = [] /** * @type {execHandler[]} * @protected */ this._execHandler = [] /** * @type {string} * @private */ this._prefix = "" /** * @type {string} * @private */ this._help = "" /** * @type {string[]} * @private */ this._manual = [] /** * @type {string} * @private */ this._name = cmd /** * @type {boolean} * @private */ this._enabled = true /** * @type {?Throttle} * @private */ this._throttle = null /** * @type {string[]} * @private */ this._alias = [] } /** * @abstract * @returns {string} */ getUsage() { throw new Error("not implemented") } /** * @abstract * @param {Client} client * @returns {Promise} */ hasPermission(client) { throw new Error("not implemented") } /** * @abstract * @param {string} args * @returns {Record} */ validate(args) { throw new Error("not implemented") } /** * @abstract * @param {string} args * @param {MessageEvent} ev */ dispatch(args, ev) { throw new Error("not implemented") } /** * one or more alias for this command * @param {...string} alias */ alias(...alias) { alias = alias.map(a => a.toLowerCase()) alias.forEach(a => Collector.isValidCommandName(a)) this._alias.push(...alias.filter(a => this._collector.getAvailableCommands(a))) return this } /** checks if the command is enabled */ isEnabled() { return this._enabled } /** * enables the current command */ enable() { this._enabled = true return this } /** * disables the current command */ disable() { this._enabled = false return this } /** gets the command name without its prefix */ getCommandName() { return this._name } /** retrieves all registered alias names without prefix */ getAlias() { return this._alias } /** gets the command name with its prefix */ getFullCommandName() { return `${this.getPrefix()}${this.getCommandName()}` } /** retrieves all registered alias names with prefix */ getFullAlias() { return this._alias.map(a => `${this.getPrefix()}${a}`) } /** retrieves all registered command names */ getCommandNames() { return [this.getCommandName(), ...this.getAlias()] } /** retrieves all registered command names with prefix */ getFullCommandNames() { return [this.getFullCommandName(), ...this.getFullAlias()] } /** retrieves the help text */ getHelp() { return this._help } /** * sets a help text (should be a very brief description) * @param {string} text help text */ help(text) { this._help = text return this } /** returns a boolean wether a help text has been set or not */ hasHelp() { return this._help !== "" } /** retrieves the current manual text */ getManual() { return this._manual.join("\r\n") } /** returns a boolean wether a help text has been set or not */ hasManual() { return this._manual.length > 0 } /** * @param {string} prefix the new prefix to set */ forcePrefix(prefix) { this._prefix = prefix return this } /** gets the current prefix for this command */ getPrefix() { if (this._prefix.length > 0) return this._prefix return Collector.getCommandPrefix() } /** * sets a manual text, this function can be called multiple times * in order to create a multilined manual text * @param {string} text the manual text */ manual(text) { this._manual.push(text) return this } /** * clears the current manual text */ clearManual() { this._manual = [] return this } /** * register an execution handler for this command * @param {execHandler} callback gets called whenever the command should do something */ exec(callback) { this._execHandler.push(callback) return this } /** * adds an instance of a throttle class * @param {Throttle} throttle adds the throttle instance */ addThrottle(throttle) { this._throttle = throttle return this } /** * @param {Client} client the sinusbot client * @private */ _handleThrottle(client) { if (!(this._throttle instanceof Throttle)) return if (this._throttle.isThrottled(client)) { const time = (this._throttle.timeTillNextCommand(client) / 1000).toFixed(1) throw new ThrottleError(`You can use this command again in ${time} seconds!`) } else { this._throttle.throttle(client) } } /** * register a permission handler for this command * @param {permissionHandler} callback gets called whenever the permission for a client gets checked */ checkPermission(callback) { this._permissionHandler.push(callback) return this } /** * checks if a client is allowed to use this command * this is the low level method to check permissions for a single command * @param {Client} client sinusbot client to check permissions from */ isAllowed(client) { return Promise.all(this._permissionHandler.map(cb => cb(client))) .then(res => res.every(r => r)) } /** * dispatches a command * @protected * @param {CommanderTextMessage} ev */ async _dispatchCommand(ev) { if (!(await this.hasPermission(ev.client))) throw new PermissionError("no permission to execute this command") this._handleThrottle(ev.client) this._execHandler.forEach(handle => handle(ev.client, ev.arguments, ev.reply, ev.raw)) } } /** * @name Command */ class Command extends BaseCommand { /** * @param {string} cmd * @param {Collector} collector */ constructor(cmd, collector) { super(cmd, collector) /** * @type {Argument[]} * @private */ this._arguments = [] } /** * Retrieves the usage of the command with its parameterized names * @returns retrieves the complete usage of the command with its argument names */ getUsage() { return `${this.getCommandName()} ${this.getArguments().map(arg => arg.getManual()).join(" ")}` } /** * checks if a client should have permission to use this command * @param {Client} client the client which should be checked */ hasPermission(client) { return this.isAllowed(client) } /** * adds an argument to the command * @param {createArgumentHandler|Argument} arg an argument to add */ addArgument(arg) { if (typeof arg === "function") arg = arg(Argument.createArgumentLayer()) if (!(arg instanceof Argument)) throw new Error(`Typeof arg should be function or instance of Argument but got ${arg}`) this._arguments.push(arg) return this } /** retrieves all available arguments */ getArguments() { return this._arguments } /** * Validates the command * @param {string} args the arguments from the command which should be validated */ validate(args) { const { result, errors, remaining } = this.validateArgs(args) if (remaining.length > 0) throw new TooManyArgumentsError(`Too many argument!`, errors.shift()) return result } /** * @param {string} args * @param {MessageEvent} ev */ dispatch(args, ev) { return this._dispatchCommand({ ...ev, arguments: this.validate(args), reply: Collector.getReplyOutput(ev), raw: ev }) } /** * Validates the given input string to all added arguments * @param {string} args the string which should get validated */ validateArgs(args) { args = args.trim() /** * @type {Record} * @private */ const result = {} /** * @type {ParseError[]} * @private */ const errors = [] this.getArguments().forEach(arg => { try { const [val, rest] = arg.validate(args) result[arg.getName()] = val return args = rest.trim() } catch (e) { if (e instanceof ParseError && arg.isOptional()) { result[arg.getName()] = arg.getDefault() return errors.push(e) } throw e } }) return { result, remaining: args, errors } } } /** * @name CommandGroup */ class CommandGroup extends BaseCommand { /** * @param {string} cmd * @param {Collector} collector */ constructor(cmd, collector) { super(cmd, collector) /** * @type {Command[]} * @private */ this._commands = [] } /** * Retrieves the usage of the command with its parameterized names * @returns retrieves the complete usage of the command with its argument names */ getUsage() { return `${this.getFullCommandName()} ${this._commands.map(cmd => cmd.getCommandName()).join("|")}` } /** * checks if a client should have permission to use this command * @param {Client} client the client which should be checked */ async hasPermission(client) { if (!await this.isAllowed(client)) return false if (this._execHandler.length > 0) return true return (await Promise.all(this._commands.map(cmd => cmd.hasPermission(client)))).some(result => result) } /** * Adds a new sub Commmand to the group * @param {string} name the sub command name which should be added */ addCommand(name) { name = name.toLowerCase() if (!Collector.isValidCommandName(name)) throw new Error("Can not create a command with length of 0") const cmd = new Command(name, this._collector) this._commands.push(cmd) return cmd } /** * Retrieves a subcommand by its command name * @param {string} name the name which should be searched for */ findCommandByName(name) { name = name.toLowerCase() if (name.length === 0) throw new CommandNotFoundError(`No subcommand specified for Command ${this.getFullCommandName()}`) const cmd = this._commands.find(c => c.getCommandNames().includes(name)) if (!cmd) throw new CommandNotFoundError(`Command with name "${name}" has not been found on Command ${this.getFullCommandName()}!`) return cmd } /** * retrievel all available subcommands * @param {Client} [client] the sinusbot client for which the commands should be retrieved if none has been omitted it will retrieve all available commands * @param {string} [cmd] the command which should be searched for */ getAvailableCommands(client, cmd) { const cmds = this._commands .filter(c => c.getCommandName() === cmd || !cmd) .filter(c => c.isEnabled()) if (!client) return Promise.resolve(cmds) return Collector.checkPermissions(cmds, client) } /** * @param {string} args * @param {MessageEvent} ev */ async dispatch(args, ev) { const [cmd, ...rest] = args.split(" ") if (!await this.hasPermission(ev.client)) throw new PermissionError("not enough permission to execute this command") if (cmd.length === 0) { return this._dispatchCommand({ ...ev, arguments: {}, reply: Collector.getReplyOutput(ev), raw: ev }) } return this.findCommandByName(cmd).dispatch(rest.join(" "), ev) } } //////////////////////////////////////////////////////////// //// Collector //// //////////////////////////////////////////////////////////// /** * @name Collector */ class Collector { constructor() { /** * @type {BaseCommand[]} * @private */ this._commands = [] } /** * retrieves the current Command Prefix * @returns {string} returns the command prefix */ static getCommandPrefix() { const prefix = engine.getCommandPrefix() if (typeof prefix !== "string" || prefix.length === 0) return "!" return prefix } /** creates a new Throttle instance */ static createThrottle() { return new Throttle() } /** * retrieves the correct reply chat from where the client has sent the message * @param {Message} event * @returns {(msg: string) => void} */ static getReplyOutput({ mode, client, channel }) { switch (mode) { case 1: return client.chat.bind(client) case 2: return channel.chat.bind(channel) case 3: return backend.chat.bind(backend) default: return msg => debug(DEBUG.WARNING)(`WARN no reply channel set for mode ${mode}, message "${msg}" not sent!`) } } /** * checks the permissions from a set of commands * @param {BaseCommand[]} commands * @param {Client} client * @returns {Promise} */ static async checkPermissions(commands, client) { const result = await Promise.all(commands.map(cmd => cmd.hasPermission(client))) return commands.filter((_, i) => result[i]) } /** * checks if the command name is valid * @param {string} name */ static isValidCommandName(name) { if (typeof name !== "string") throw new Error("Expected a string as command name!") if (name.length < 1) throw new Error(`Command should have a minimum length of 1!`) if ((/\s/).test(name)) throw new Error(`Command "${name}" should not contain spaces!`) return true } /** * get all available commands from its command string * @param {string} name */ getAvailableCommands(name) { name = name.toLowerCase() return this._commands .filter(cmd => cmd.isEnabled()) .filter(cmd => cmd.getCommandNames().includes(name)) } /** * retrieves all available permissions for a certain client * @param {Client} client */ getAvailableCommandsByPermission(client) { return Collector.checkPermissions( this._commands.filter(cmd => cmd.isEnabled()), client ) } /** * Searches for one or multiple enabled commands with its prefix * @param {string} name the command with its prefix * @returns {BaseCommand[]} returns an array of found commands */ getAvailableCommandsWithPrefix(name) { name = name.toLowerCase() return this._commands .filter(cmd => cmd.isEnabled()) .filter(cmd => cmd.getFullCommandNames().includes(name)) } /** * checks if a command is a possible command string * @param {string} text */ isPossibleCommand(text) { if (text.startsWith(Collector.getCommandPrefix())) return true return this._commands.some(cmd => cmd.getFullCommandNames().includes(text.split(" ")[0])) } /** * creates a new command * @param {string} name the name of the command */ registerCommand(name) { name = name.toLowerCase() if (!Collector.isValidCommandName(name)) throw new Error("Can not create a command with length of 0") const cmd = new Command(name, this) this._commands.push(cmd) return cmd } /** * creates a new command * @param {string} name the name of the command */ registerCommandGroup(name) { name = name.toLowerCase() if (!Collector.isValidCommandName(name)) throw new Error("Can not create a command with length of 0") const cmd = new CommandGroup(name, this) this._commands.push(cmd) return cmd } /** * checks if the command string is save to register as a new command * this function basically checks if there is no other command named with * throws an error when Collector#validateCommandName errors * returns false when this command has been already registered * returns true when this is a completely unused command * @param {string} cmd */ isSaveCommand(cmd) { cmd = cmd.toLowerCase() Collector.isValidCommandName(cmd) if (this.getAvailableCommands(cmd).length > 0) return false return true } } //////////////////////////////////////////////////////////// //// Logic //// //////////////////////////////////////////////////////////// /** @name collector */ const collector = new Collector() collector.registerCommand("help") .help("Displays this text") .manual(`Displays a list of useable commands`) .manual(`you can search/filter for a specific commands by adding a keyword`) .addArgument(arg => arg.string.setName("filter").min(1).optional()) .exec(async (client, { filter }, reply) => { /** * @param {string} str * @param {number} len * @private */ const fixLen = (str, len) => str + Array(len - str.length).fill(" ").join("") let length = 0 const cmds = (await collector.getAvailableCommandsByPermission(client)) .filter(cmd => cmd.hasHelp()) .filter(cmd => !filter || cmd.getCommandName().match(new RegExp(filter, "i")) || cmd.getHelp().match(new RegExp(filter, "i"))) reply(`${format.bold(cmds.length.toString())} Commands found:`) /** * @type {string[][]} * @private */ const commands = [] await Promise.all(cmds.map(async cmd => { if (cmd instanceof CommandGroup) { if (cmd.getFullCommandName().length > length) length = cmd.getFullCommandName().length ;(await cmd.getAvailableCommands(client)).forEach(sub => { if (cmd.getFullCommandName().length + sub.getCommandName().length + 1 > length) length = cmd.getFullCommandName().length + sub.getCommandName().length + 1 commands.push([`${cmd.getFullCommandName()} ${sub.getCommandName()}`, sub.getHelp()]) }) } else { if (cmd.getFullCommandName().length > length) length = cmd.getFullCommandName().length commands.push([cmd.getFullCommandName(), cmd.getHelp()]) } })) /** * @type {string[][]} * @private */ const init = [[]] switch (engine.getBackend()) { case "discord": return commands .map(([cmd, help]) => `${fixLen(cmd, length)} ${help}`) .reduce((acc, curr) => { if (acc[acc.length - 1].length + acc.join("\n").length + 6 >= 2000) { acc[acc.length] = [curr] } else { acc[acc.length - 1].push(curr) } return acc }, init) .forEach(lines => reply(format.code(lines.join("\n")))) default: case "ts3": return commands .map(([cmd, help]) => `${format.bold(cmd)} ${help}`) .reduce((acc, curr) => { if (acc[acc.length - 1].length + acc.join("\n").length + 2 >= 8192) { acc[acc.length] = [curr] } else { acc[acc.length - 1].push(curr) } return acc }, init) .forEach(lines => reply(`\n${lines.join("\n")}`)) } }) //creates the man command collector.registerCommand("man") .help("Displays detailed help about a command if available") .manual(`Displays detailed usage help for a specific command`) .manual(`Arguments with Arrow Brackets (eg. < > ) are mandatory arguments`) .manual(`Arguments with Square Brackets (eg. [ ] ) are optional arguments`) .addArgument(arg => arg.string.setName("command").min(1)) .addArgument(arg => arg.string.setName("subcommand").min(1).optional(false, false)) .exec(async (client, { command, subcommand }, reply) => { /** * @param {BaseCommand} cmd * @private */ const getManual = cmd => { if (cmd.hasManual()) return cmd.getManual() if (cmd.hasHelp()) return cmd.getHelp() return "No manual available" } const cmds = await Collector.checkPermissions(collector.getAvailableCommands(command), client) if (cmds.length === 0) return reply(`No command with name ${format.bold(command)} found! Did you misstype the command?`) cmds.forEach(async cmd => { if (cmd instanceof CommandGroup) { if (subcommand) { (await cmd.getAvailableCommands(client, subcommand)).forEach(sub => { reply(`\n${format.bold("Usage:")} ${cmd.getFullCommandName()} ${sub.getUsage()}\n${getManual(sub)}`) }) } else { reply(`${format.bold(cmd.getFullCommandName())} - ${getManual(cmd)}`) ;(await cmd.getAvailableCommands(client)).forEach(sub => { reply(`${format.bold(`${cmd.getFullCommandName()} ${sub.getUsage()}`)} - ${sub.getHelp()}`) }) } } else { let response = `\nManual for command: ${format.bold(cmd.getFullCommandName())}\n${format.bold("Usage:")} ${cmd.getUsage()}\n${getManual(cmd)}` if (cmd.getAlias().length > 0) response += `\n${format.bold("Alias")}: ${cmd.getAlias()}` reply(response) } }) }) if (engine.getBackend() === "discord") { //discord message handler event.on("message", ev => { let author = ev.author() if (!author) { const id = ev.authorID() const guild = backend.getBotClientID().split("/")[0] const clid = `${guild}/${id}` if (id) { author = backend.getClientByID(clid) } else { debug(DEBUG.VERBOSE)("authorID is undefined") } if (!author) { debug(DEBUG.WARNING)(`could not get author with ID=${id}; replacing client with workaround`) //simulate the basic functionality of a client object author = { // eslint-disable-next-line arrow-parens chat: (/** @type {string} */ str) => ev.reply(str), isSelf: () => false, id: () => clid, uid: () => clid, uniqueId: () => clid, uniqueID: () => clid, DBID: () => clid, databaseID: () => clid, databaseId: () => clid, type: () => 1, getURL: () => `<@${id}>`, name: () => `unknown (ID: ${id})`, nick: () => `unknown (ID: ${id})`, phoneticName: () => '', description: () => '', getServerGroups: () => [], getChannelGroup: () => null, getChannels: () => [], getAudioChannel: () => null, // eslint-disable-next-line arrow-parens equals: (/** @type {Client} */ client) => { const uid = client.uid().split("/") if (uid.length === 2) { return uid[2] === id } else { return client.uid() === clid } } } } } messageHandler({ text: ev.content(), channel: ev.channel(), client: author, mode: ev.guildID() ? 2 : 1, message: ev }) }) } else { //teamspeak message handler event.on("chat", messageHandler) } /** * Handles chat/message events * @private * @param {MessageEvent} ev */ function messageHandler(ev) { if (typeof engine.getIgnoreCommandsFromPrivateChat === "function") { //check ignore private chat if (ev.mode === 1 && engine.getIgnoreCommandsFromPrivateChat()) return debug(DEBUG.VERBOSE)("ignoring private chat due to sinusbot instance settings") //check ignore channel chat if (ev.mode === 2 && engine.getIgnoreCommandsFromChannelChat()) return debug(DEBUG.VERBOSE)("ignoring channel chat due to sinusbot instance settings") //check ignore server chat if (ev.mode === 3 && engine.getIgnoreCommandsFromServerChat()) return debug(DEBUG.VERBOSE)("ignoring server chat due to sinusbot instance settings") } //do not do anything when the client is undefined if (!ev.client) return debug(DEBUG.WARNING)("client is undefined") //do not do anything when the bot sends a message if (ev.client.isSelf()) return debug(DEBUG.VERBOSE)("Will not handle messages from myself") //check if it is a possible command if (!collector.isPossibleCommand(ev.text)) return debug(DEBUG.VERBOSE)("No possible valid command found!") //get the basic command with arguments and command splitted const match = ev.text.match(new RegExp(`^(?\\S*)\\s*(?.*)\\s*$`, "s")) if (!match || !match.groups) throw new Error(`command regex missmatch for '${ev.text}'`) const { command, args } = match.groups //check if command exists const commands = collector.getAvailableCommandsWithPrefix(command) if (commands.length === 0) { //depending on the config setting return without error if (NOT_FOUND_MESSAGE !== "0") return //send the not found message return Collector.getReplyOutput(ev)(`There is no enabled command named ${format.bold(command.toLowerCase())}, check ${format.bold(`${Collector.getCommandPrefix()}help`)} to get a list of available commands!`) } //handle every available command, should actually be only one command commands.forEach(async cmd => { const start = Date.now() try { debug(DEBUG.INFO)(`${ev.client.name()} (${ev.client.uid()}) used ${cmd.getFullCommandName()}`) //dispatches the cmd, this will // - check for permissions // - parse the arguments // - dispatch the command await cmd.dispatch(args, ev) debug(DEBUG.VERBOSE)(`Command "${cmd.getFullCommandName()}" finnished successfully after ${Date.now() - start}ms`) //catch errors, parsing errors / permission errors or anything else } catch (e) { debug(DEBUG.VERBOSE)(`Command "${cmd.getFullCommandName()}" failed after ${Date.now() - start}ms`) const reply = Collector.getReplyOutput(ev) //Handle Command not found Exceptions for CommandGroups let response = (engine.getBackend() === "ts3" ? "\n" : "") if (e instanceof CommandNotFoundError) { response += `${e.message}\n` response += `For Command usage see ${format.bold(`${Collector.getCommandPrefix()}man ${cmd.getCommandName()}`)}\n` reply(response) } else if (e instanceof PermissionError) { debug(DEBUG.INFO)(`${ev.client.name()} (${ev.client.uid()}) is missing permissions for ${cmd.getFullCommandName()}`) response += `You do not have permissions to use this command!\n` response += `To get a list of available commands see ${format.bold(`${Collector.getCommandPrefix()}help`)}` reply(response) } else if (e instanceof ParseError) { response += `Invalid Command usage! For Command usage see ${format.bold(`${Collector.getCommandPrefix()}man ${cmd.getCommandName()}`)}\n` reply(response) } else if (e instanceof ThrottleError) { reply(e.message) } else if (e instanceof TooManyArgumentsError) { response += `Too many Arguments received for this Command!\n` if (e.parseError) { response += `Argument parsed with an error ${format.bold(e.parseError.argument.getManual())}\n` response += `Returned with ${format.bold(e.parseError.message)}\n` } response += `Invalid Command usage! For Command usage see ${format.bold(`${Collector.getCommandPrefix()}man ${cmd.getCommandName()}`)}` reply(response) } else { reply("An unhandled exception occured, check the sinusbot logs for more informations") const match = e.stack.match(new RegExp("^(?\\w+): *(?.+?)\\s+(at .+?\\(((?