diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..c6c651d --- /dev/null +++ b/scripts/README.md @@ -0,0 +1 @@ +### Default attached scripts of the SinusBot which are ported to the v8 scripting engine \ No newline at end of file diff --git a/scripts/advertising.js b/scripts/advertising.js new file mode 100644 index 0000000..a77bb00 --- /dev/null +++ b/scripts/advertising.js @@ -0,0 +1,74 @@ +registerPlugin({ + name: 'Advertising (Text)', + version: '3.0.0', + backends: ['ts3'], + description: 'This script will announce one of the configured lines every x seconds.', + author: 'SinusBot Team', // Michael Friese, Max Schmitt, Jonas Bögle + vars: [{ + name: 'ads', + title: 'Ads (supports bbcode)', + type: 'multiline', + placeholder: 'Welcome to the best TS3-Server!' + }, { + name: 'interval', + title: 'Interval (in seconds)', + type: 'number', + placeholder: '5', + default: 5 + }, { + name: 'order', + title: 'Order', + type: 'select', + options: [ + 'line by line (default)', + 'random' + ], + default: '0' + }, { + name: 'type', + title: 'Broadcast-Type', + type: 'select', + options: [ + 'Channel', + 'Server' + ], + default: '0' + }] +}, (_, {ads, order, type, interval}) => { + const backend = require('backend') + const engine = require('engine') + + ads = ads ? ads.split('\n').map(line => line.trim().replace(/\r/g, '')) : [] + + if (ads.length === 0) { + engine.log('There are no ads configured.') + return + } + if (interval <= 3) { + engine.log('The interval is too small, use a value bigger than 3 seconds.') + return + } + + const RANDOM = '1'; + const SERVER = '1'; + + let index = -1 + + setInterval(() => { + switch (order) { + case RANDOM: + index = Math.floor(Math.random() * ads.length) + break + default: + index = (++index % ads.length) + } + + switch (type) { + case SERVER: + backend.chat(ads[index]) + break + default: + backend.getCurrentChannel().chat(ads[index]) + } + }, interval * 1000) +}) \ No newline at end of file diff --git a/scripts/alonemode.js b/scripts/alonemode.js new file mode 100644 index 0000000..f50f39a --- /dev/null +++ b/scripts/alonemode.js @@ -0,0 +1,62 @@ +registerPlugin({ + name: 'AloneMode', + version: '3.2.0', + backends: ['ts3', 'discord'], + description: 'This script will save CPU and bandwidth by stopping or muting the bot when nobody is listening anyways.', + author: 'SinusBot Team', // Michael Friese, Max Schmitt, Jonas Bögle, Fabian "fabm3n" + vars: [{ + name: 'mode', + title: 'Mode', + type: 'select', + options: [ + 'mute only', + 'stop playback' + ] + }] +}, (_, {mode}) => { + const engine = require('engine') + const backend = require('backend') + const event = require('event') + const audio = require('audio') + const media = require('media') + + const MUTE_ONLY = '0' + + let isMuted = false + let lastPosition = 0 + let lastTrack + + audio.setMute(false) + + event.on('clientMove', () => { + let currentChannel = backend.getCurrentChannel() + let clients = currentChannel ? currentChannel.getClientCount() : 0 + + if (clients > 1 && isMuted) { + isMuted = false + engine.log('Ending AloneMode...') + + if (mode == MUTE_ONLY) { + audio.setMute(false) + } else { + if (lastTrack) { + lastTrack.play() + audio.seek(lastPosition) + engine.log(`Seeking to ${lastPosition} of track '${lastTrack.title()}'`) + } + } + } else if (clients <= 1 && audio.isPlaying() && isMuted == false) { + isMuted = true + engine.log('Starting AloneMode...') + + if (mode == MUTE_ONLY) { + audio.setMute(true) + } else { + lastPosition = audio.getTrackPosition() + lastTrack = media.getCurrentTrack() + engine.log(`Position ${lastPosition} saved for track '${lastTrack.title()}'`) + media.stop() + } + } + }) +}) diff --git a/scripts/bookmark.js b/scripts/bookmark.js new file mode 100644 index 0000000..60e047d --- /dev/null +++ b/scripts/bookmark.js @@ -0,0 +1,51 @@ +registerPlugin({ + name: 'Bookmarks!', + version: '3.0.0', + backends: ['ts3', 'discord'], + description: 'Enter .bookmark to save the current position, enter .resume to seek to the bookmarked position.', + author: 'SinusBot Team', // Michael Friese, Max Schmitt, Jonas Bögle + vars: [] +}, () => { + const store = require('store') + const media = require('media') + const audio = require('audio') + const event = require('event') + + event.on('load', () => { + //try to load the library + const Command = require('command') + //check if the library has been loaded successfully + if (!Command) throw new Error('Command.js library not found! Please download Command.js and enable it to be able use this script!') + + Command.createCommand('bookmark') + .help('saves the current position') + .manual('saves the current position') + .manual('can be resumed by the \'resume\' command. (Seeks to the bookmarked position of the track)') + .exec((client, args, reply) => { + const track = media.getCurrentTrack() + if (!track) { + return + } + const pos = audio.getTrackPosition() + store.set(track.id(), pos) + reply(`Position saved for track '${track.title()}' at ${pos} ms`) + }) + + Command.createCommand('resume') + .help('resumes to the bookmarked position') + .manual('resumes to the bookmarked position (use bookmark command to set)') + .exec((client, args, reply) => { + const track = media.getCurrentTrack() + if (!track) { + return + } + const pos = store.get(track.id()) + if (!pos) { + reply('No position found, sorry.') + return + } + audio.seek(pos) + reply(`Resumed at ${pos} ms of track '${track.title()}'`) + }) + }) +}) \ No newline at end of file diff --git a/scripts/command.js b/scripts/command.js new file mode 100644 index 0000000..34d236c --- /dev/null +++ b/scripts/command.js @@ -0,0 +1,1875 @@ +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 .+?\\(((?