sinusbot/scripts/command.js

1875 lines
54 KiB
JavaScript

registerPlugin({
name: "Command Library",
description: "Library to handle and manage commands",
version: "1.4.4",
author: "Multivitamin <david.kartnaller@gmail.com>",
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<string, any>} 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<string, any>} 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*\/(?<url_uid>[/+a-z0-9]{27}=)~.*\].*\[\/URL\]|(?<uid>[/+a-z0-9]{27}=)) *(?<rest>.*)$/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(/^(<@(?<id>\d{18})>|@(?<name>.*?)#\d{4}) *(?<rest>.*)$/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<string, any>}
* @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<string, any>}
* @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<string, ThrottleInterface>}
* @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<boolean>}
*/
hasPermission(client) {
throw new Error("not implemented")
}
/**
* @abstract
* @param {string} args
* @returns {Record<string, any>}
*/
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<string, any>}
* @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<BaseCommand[]>}
*/
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(`^(?<command>\\S*)\\s*(?<args>.*)\\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("^(?<type>\\w+): *(?<msg>.+?)\\s+(at .+?\\(((?<script>\\w+):(?<line>\\d+):(?<row>\\d+))\\))", "s"))
if (match) {
const { type, msg, script, line, row } = match.groups
debug(DEBUG.ERROR)(`Unhandled Script Error in Script "${script.endsWith(".js") ? script : `${script}.js`}" on line ${line} at index ${row}`)
debug(DEBUG.ERROR)(`${type}: ${msg}`)
debug(DEBUG.VERBOSE)(e.stack)
} else {
debug(DEBUG.ERROR)("This is _probably_ an Error with a Script which is using command.js!")
debug(DEBUG.ERROR)(e.stack)
}
}
}
})
}
////////////////////////////////////////////////////////////
//// EXPORTS ////
////////////////////////////////////////////////////////////
/**
* @name createCommandGroup
* Creates a new CommandsCommand Instance with the given Command Name
* @param {string} cmd - the command which should be added
* @returns {CommandGroup} returns the created CommandGroup instance
*/
function createCommandGroup(cmd) {
if (!collector.isSaveCommand(cmd)) {
debug(DEBUG.WARNING)(`WARNING there is already a command with name '${cmd}' enabled!`)
debug(DEBUG.WARNING)(`command.js may work not as expected!`)
}
debug(DEBUG.VERBOSE)(`registering commandGroup '${cmd}'`)
return collector.registerCommandGroup(cmd)
}
/**
* @name createCommand
* Creates a new Command Instance with the given Command Name
* @param {string} cmd - the command which should be added
* @returns {Command} returns the created Command
*/
function createCommand(cmd) {
if (!collector.isSaveCommand(cmd)) {
debug(DEBUG.WARNING)(`WARNING there is already a command with name '${cmd}' enabled!`)
debug(DEBUG.WARNING)(`command.js may work not as expected!`)
}
debug(DEBUG.VERBOSE)(`registering command '${cmd}'`)
return collector.registerCommand(cmd)
}
/**
* @name createArgument
* Creates a new Argument Instance
* @param {keyof ArgType} type - the argument type which should be created
* @returns {Argument} returns the created Argument
*/
function createArgument(type) {
const arg = Argument.createArgumentLayer()[type]
if (!(arg instanceof Argument))
throw new Error(`Argument type not found! Available Arguments: ${Object.keys(Argument.createArgumentLayer()).join(", ")}`)
return arg
}
/**
* @name createGroupedArgument
* creates a new Argument Instance
* @param {"or"|"and"} type the argument type which should be created either "or" or "and" allowed
* @returns {GroupArgument} returns the created Group Argument
*/
function createGroupedArgument(type) {
if (!Object.values(["or", "and"]).includes(type))
throw new Error(`Unexpected GroupArgument type, expected one of ["or", "and"] but got ${type}!`)
return new GroupArgument(type)
}
/**
* @name getCommandPrefix
* retrieves the current Command Prefix
* @returns {string} returns the command prefix
*/
function getCommandPrefix() {
return Collector.getCommandPrefix()
}
/**
* @name createThrottle
* Creates a new Throttle Instance
* @returns {Throttle} returns the created Throttle
*/
function createThrottle() {
return Collector.createThrottle()
}
/**
* @name getVersion
* retrieves the semantic version of this script
* @returns {string} returns the semantic version of this script
*/
function getVersion() {
return version
}
module.exports = {
createCommandGroup,
createCommand,
createArgument,
createGroupedArgument,
getCommandPrefix,
createThrottle,
getVersion,
collector
}
})