diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8ca41d7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog +All notable changes to the discord bot will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.10.0] - 2019-03-03 +### Added +- AniList api commands powered by [AniList.co](https://www.anilist.co) +- MessageHandler - handles all incoming messages, parses the syntax, executes the syntax and handles rate limits +- CommandHandler - handles all single commands, checks command Permission and executes the command +- Command - represents a single command with the necessary metadata and answer instance +- Answer - represents a commands answer with own syntax parsing (can be overwritten) +- CommandModule - represents a single module of a command with the initialization and registring of command to the command handler. Each module owns an instance of the logger +- ExtendedRichEmbed - extends the functinality of the default discord.js RichEmbed with auto cropping of too long field values, functions to add an Object with fields that are not empty and automatic timestamp addition + +### Changed +- Command Syntax now orients more on linux/unix style with `&&` and `;` +- GuildHandler now doesn't handle commands anymore +- the default logger is now a wrapper around the winston.js logger that loggs the current module's name +- all commands are now defined in the lib/commands folder with a folder for each command that contains a `index.js` and a `CommandTemplate.yaml`. +- Rate Limits now only affect commands +- Music commands `~skip` and `~stop` now are votable when the user doesn't have the role *dj* or *botcommander* +- renamed the lib/music to lib/MusicLib and the DJ class to MusicHandler class +- renamed the lib/weblib to lib/WebLib +- changed graphql schema to fit the new internal names +- changed interface to fit the new graphql schema +- changed module export definition to `Object.assign(exports, {...})` at the end of the module file +- added section `commandSettings` to config.js file +- added module information to webinterface log + +### Removed +- removed lib/cmd because all functionalities are now adapted to the MessageHandler and CommadnHandlers diff --git a/README.md b/README.md index 47a751c..42ab00f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ The arguments are optional because the token and youtube-api-key that the bot ne "keyFile": "PATH TO YOUR SSL KEY FILE", "certFile": "PATH TO YOUR SSL CERTIFICATE FILE" } + }, + "commandSettings": { + "maxSequenceParallel": 5, // the maximum number of commands executed in parallel + "maxSequenceSerial": 10 // the maximum number of commands executed in serial } } ``` @@ -63,6 +67,7 @@ At the moment the bot can... - [x] ...log stuff in a database - [x] ...execute multiple commands as a sequence - [x] ...save command sequences with a given name +- [x] ...query AniList - [ ] ...transform into a cow Presences @@ -76,15 +81,19 @@ Command Sequences A command sequence is a single message with several commands seperated by a semicolon. In a sequence the command can be ommitted if it is the same as the previous one. That means you can add several videos to the queue and shuffle it afterwards with the sequence - `~play [video1]; [video2]; [video3]; ~shuffle`. + `~play [video1] && ~play [video2]; ~play [video3] && ~shuffle`. - A command sequence can be saved with `~savecmd [sequence] [commandname]`. - In this case the semicolon must be escaped with a backslash so it won't get interpreted as a seperate command. + A command sequence can be saved with `~savecmd [commandname] [sequence]`. + In this case the semicolon must be escaped with a backslash so it won't get interpreted as a seperate command. You can also escape sequences with `~play "whatever &&; you want"` (doublequotes). Command sequences with `&&` are executed in serial while command sequences with `;` are executed in parallel. A saved command can be executed with `~execute [commandname]`. +References +--- + +You can test a running version of the bot. [Invite bot server](https://discordapp.com/oauth2/authorize?client_id=374703138575351809&scope=bot&permissions=1983380544) + Ideas --- - command replies saved in file (server specific file and global file) - reddit api -- anilist api - othercoolstuff api diff --git a/bot.js b/bot.js index 02e1c63..c2fe03c 100644 --- a/bot.js +++ b/bot.js @@ -1,44 +1,47 @@ const Discord = require("discord.js"), fs = require('fs-extra'), - logger = require('./lib/logging').getLogger(), - cmd = require("./lib/cmd"), + logging = require('./lib/logging'), + msgLib = require('./lib/MessageLib'), guilding = require('./lib/guilding'), utils = require('./lib/utils'), config = require('./config.json'), args = require('args-parser')(process.argv), - waterfall = require('promise-waterfall'), sqliteAsync = require('./lib/sqliteAsync'), - globcommands = require('./commands/globalcommands.json'), authToken = args.token || config.api.botToken, prefix = args.prefix || config.prefix || '~', gamepresence = args.game || config.presence; let weblib = null; +/** + * The Bot class handles the initialization and Mangagement of the Discord bot and + * is the main class. + */ class Bot { + constructor() { this.client = new Discord.Client({autoReconnect: true}); - this.mention = false; + this.logger = new logging.Logger(this); this.rotator = null; this.maindb = null; this.presences = []; - this.guildHandlers = []; - this.userRates = {}; + this.messageHandler = new msgLib.MessageHandler(this.client); + this.guildHandlers = {}; - logger.verbose('Verifying config'); + this.logger.verbose('Verifying config'); let configVerifyer = new utils.ConfigVerifyer(config, [ - "api.botToken", "api.youTubeApiKey" + "api.botToken", "api.youTubeApiKey", + "commandSettings.maxSequenceParallel", + "commandSettings.maxSequenceSerial" ]); - if (!configVerifyer.verifyConfig(logger)) + if (!configVerifyer.verifyConfig(this.logger)) if (!args.i) { - logger.info('Invalid config. Exiting'); - logger.flush().then(() => { + this.logger.info('Invalid config. Exiting'); + this.logger.flush().then(() => { process.exit(1); }); } - cmd.setLogger(logger); - guilding.setLogger(logger); } /** @@ -46,27 +49,44 @@ class Bot { * @returns {Promise} */ async initServices() { - logger.verbose('Registering cleanup function'); + this.logger.verbose('Registering cleanup function'); + utils.Cleanup(() => { for (let gh in Object.values(this.guildHandlers)) if (gh instanceof guilding.GuildHandler) gh.destroy(); this.client.destroy().then(() => { - logger.debug('destroyed client'); + this.logger.debug('destroyed client'); }).catch((err) => { - logger.error(err.message); - logger.debug(err.stack); + this.logger.error(err.message); + this.logger.debug(err.stack); }); this.maindb.close(); }); await this.initializeDatabase(); - if (config.webservice && config.webservice.enabled) + + if (config.webinterface && config.webinterface.enabled) await this.initializeWebserver(); - logger.verbose('Registering commands'); - this.registerCommands(); - this.registerCallbacks(); - cmd.init(prefix); + this.logger.verbose('Registering commands'); + await this.messageHandler.registerCommandModule(require('./lib/commands/AnilistApiCommands').module, {}); + await this.messageHandler.registerCommandModule(require('./lib/commands/UtilityCommands').module, { + bot: this, + config: config + }); + await this.messageHandler.registerCommandModule(require('./lib/commands/InfoCommands').module, { + client: this.client, + messageHandler: this.messageHandler + }); + await this.messageHandler.registerCommandModule(require('./lib/commands/MusicCommands').module, { + getGuildHandler: async (g) => await this.getGuildHandler(g) + }); + await this.messageHandler.registerCommandModule(require('./lib/commands/ServerUtilityCommands').module, { + getGuildHandler: async (g) => await this.getGuildHandler(g), + messageHandler: this.messageHandler, + config: config + }); + this.registerEvents(); } /** @@ -75,10 +95,11 @@ class Bot { */ async start() { await this.client.login(authToken); - logger.debug("Logged in"); + this.logger.debug("Logged in"); + if (this.webServer) { this.webServer.start(); - logger.info(`WebServer runing on port ${this.webServer.port}`); + this.logger.info(`WebServer runing on port ${this.webServer.port}`); } } @@ -87,16 +108,17 @@ class Bot { * @returns {Promise} */ async initializeDatabase() { - logger.debug('Checking for ./data/ existence'); + this.logger.debug('Checking for ./data/ existence'); await fs.ensureDir('./data'); - logger.verbose('Connecting to main database'); + this.logger.verbose('Connecting to main database'); this.maindb = new sqliteAsync.Database('./data/main.db'); await this.maindb.init(); + await this.maindb.run(`${utils.sql.tableExistCreate} presences ( ${utils.sql.pkIdSerial}, text VARCHAR(255) UNIQUE NOT NULL )`); - logger.debug('Loading Presences...'); + this.logger.debug('Loading Presences...'); await this.loadPresences(); } @@ -104,19 +126,18 @@ class Bot { * initializes the api webserver */ async initializeWebserver() { - logger.verbose('Importing weblib'); - weblib = require('./lib/weblib'); - weblib.setLogger(logger); - logger.verbose('Creating WebServer'); - this.webServer = new weblib.WebServer(config.webservice.port || 8080); - logger.debug('Setting Reference Objects to webserver'); + this.logger.verbose('Importing weblib'); + weblib = require('./lib/WebLib'); + this.logger.verbose('Creating WebServer'); + this.webServer = new weblib.WebServer(config.webinterface.port || 8080); + this.logger.debug('Setting Reference Objects to webserver'); await this.webServer.setReferenceObjects({ client: this.client, presences: this.presences, maindb: this.maindb, prefix: prefix, - getGuildHandler: (guild) => this.getGuildHandler(guild, prefix), + getGuildHandler: async (g) => await this.getGuildHandler(g), guildHandlers: this.guildHandlers }); } @@ -136,7 +157,7 @@ class Bot { lineReader.on('line', (line) => { this.maindb.run('INSERT INTO presences (text) VALUES (?)', [line], (err) => { if (err) - logger.warn(err.message); + this.logger.warn(err.message); }); this.presences.push(line); @@ -157,115 +178,6 @@ class Bot { } } - /** - * registeres global commands - */ - registerCommands() { - // useless test command - cmd.createGlobalCommand(prefix, globcommands.utils.say, (msg, argv, args) => { - return args.join(' '); - }); - - // adds a presence that will be saved in the presence file and added to the rotation - cmd.createGlobalCommand(prefix, globcommands.utils.addpresence, async (msg, argv, args) => { - let p = args.join(' '); - this.presences.push(p); - - await this.maindb.run('INSERT INTO presences (text) VALUES (?)', [p]); - return `Added Presence \`${p}\``; - }); - - // shuts down the bot after destroying the client - cmd.createGlobalCommand(prefix, globcommands.utils.shutdown, async (msg) => { - try { - await msg.reply('Shutting down...'); - logger.debug('Destroying client...'); - } catch (err) { - logger.error(err.message); - logger.debug(err.stack); - } - try { - await this.client.destroy(); - logger.debug('Exiting server...'); - } catch (err) { - logger.error(err.message); - logger.debug(err.stack); - } - try { - await this.webServer.stop(); - logger.debug(`Exiting Process...`); - process.exit(0); - } catch (err) { - logger.error(err.message); - logger.debug(err.stack); - } - }); - - // forces a presence rotation - cmd.createGlobalCommand(prefix, globcommands.utils.rotate, () => { - try { - this.client.clearInterval(this.rotator); - this.rotatePresence(); - this.rotator = this.client.setInterval(() => this.rotatePresence(), config.presence_duration); - } catch (error) { - logger.warn(error.message); - } - }); - - // ping command that returns the ping attribute of the client - cmd.createGlobalCommand(prefix, globcommands.info.ping, () => { - return `Current average ping: \`${this.client.ping} ms\``; - }); - - // returns the time the bot is running - cmd.createGlobalCommand(prefix, globcommands.info.uptime, () => { - let uptime = utils.getSplitDuration(this.client.uptime); - return new Discord.RichEmbed().setDescription(` - **${uptime.days}** days - **${uptime.hours}** hours - **${uptime.minutes}** minutes - **${uptime.seconds}** seconds - **${uptime.milliseconds}** milliseconds - `).setTitle('Uptime'); - }); - - // returns the numbe of guilds, the bot has joined - cmd.createGlobalCommand(prefix, globcommands.info.guilds, () => { - return `Number of guilds: \`${this.client.guilds.size}\``; - }); - - cmd.createGlobalCommand(prefix, globcommands.utils.createUser, (msg, argv) => { - return new Promise((resolve, reject) => { - if (msg.guild) { - resolve("It's not save here! Try again via PM."); - } else if (argv.username && argv.scope) { - logger.debug(`Creating user entry ${argv.username}, scope: ${argv.scope}`); - - this.webServer.createUser(argv.username, argv.password, argv.scope, false).then((token) => { - resolve(`Created entry - username: ${argv.username}, - scope: ${argv.scope}, - token: ${token} - `); - }).catch((err) => reject(err.message)); - } - }); - }); - - cmd.createGlobalCommand(prefix, globcommands.info.about, () => { - return new Discord.RichEmbed() - .setTitle('About') - .setDescription(globcommands.info.about.response.about_creator) - .addField('Icon', globcommands.info.about.response.about_icon); - }); - - cmd.createGlobalCommand(prefix, globcommands.utils.bugreport, () => { - return new Discord.RichEmbed() - .setTitle('Where to report a bug?') - .setDescription(globcommands.utils.bugreport.response.bug_report); - }); - } - /** * changes the presence of the bot by using one stored in the presences array */ @@ -276,118 +188,54 @@ class Bot { this.client.user.setPresence({ game: {name: `${gamepresence} | ${pr}`, type: "PLAYING"}, status: 'online' - }).then(() => logger.debug(`Presence rotation to ${pr}`)) - .catch((err) => logger.warn(err.message)); + }).then(() => this.logger.debug(`Presence rotation to ${pr}`)) + .catch((err) => this.logger.warn(err.message)); } /** * Registeres callbacks for client events message and ready */ - registerCallbacks() { + registerEvents() { this.client.on('error', (err) => { - logger.error(err.message); - logger.debug(err.stack); + this.logger.error(err.message); + this.logger.debug(err.stack); }); this.client.on('ready', () => { - logger.info(`logged in as ${this.client.user.tag}!`); + this.logger.info(`logged in as ${this.client.user.tag}!`); + this.client.user.setPresence({ game: { name: gamepresence, type: "PLAYING" }, status: 'online' - }) - .catch((err) => { - if (err) - logger.warn(err.message); - }); - }); - - this.client.on('message', async (msg) => { - try { - if (msg.author === this.client.user) { - logger.verbose(`ME: ${msg.content}`); - return; - } - if (this.checkRate(msg.author.tag)) { - logger.verbose(`<${msg.author.tag}>: ${msg.content}`); - if (!msg.guild) { - let reply = cmd.parseMessage(msg); - await this.answerMessage(msg, reply); - } else { - let gh = await this.getGuildHandler(msg.guild, prefix); - await gh.handleMessage(msg); - } - if (((Date.now() - this.userRates[msg.author.tag].last)/1000) > (config.rateLimitTime || 10)) - this.userRates[msg.author.tag].count = 0; - else - this.userRates[msg.author.tag].count++; - this.userRates[msg.author.tag].last = Date.now(); - this.userRates[msg.author.tag].reached = false; - } else if (!this.userRates[msg.author.tag].reached) { - logger.verbose(`${msg.author.tag} reached it's rate limit.`); - this.userRates[msg.author.tag].reached = true; - } - } catch (err) { - logger.error(err.message); - logger.debug(err.stack); - } + }).catch((err) => { + if (err) + this.logger.warn(err.message); + }); }); this.client.on('voiceStateUpdate', async (oldMember, newMember) => { - let gh = await this.getGuildHandler(newMember.guild, prefix); + let gh = await this.getGuildHandler(newMember.guild); + if (newMember.user === this.client.user) { if (newMember.voiceChannel) - gh.dj.updateChannel(newMember.voiceChannel); + gh.musicPlayer.updateChannel(newMember.voiceChannel); } else { - if (oldMember.voiceChannel === gh.dj.voiceChannel || newMember.voiceChannel === gh.dj.voiceChannel) - gh.dj.checkListeners(); + if (oldMember.voiceChannel === gh.musicPlayer.voiceChannel || newMember.voiceChannel === gh.musicPlayer.voiceChannel) + gh.musicPlayer.checkListeners(); } }); } - /** - * Returns true if the user has not reached it's rate limit. - * @param usertag - * @returns {boolean} - */ - checkRate(usertag) { - if (!this.userRates[usertag]) - this.userRates[usertag] = {last: Date.now(), count: 0}; - return ((Date.now() - this.userRates[usertag].last)/1000) > (config.rateLimitTime || 10) || - this.userRates[usertag].count < (config.rateLimitCount || 5); - } - - /** - * Sends the answer recieved from the commands callback. - * Handles the sending differently depending on the type of the callback return - * @param msg - * @param answer - */ - async answerMessage(msg, answer) { - if (answer instanceof Discord.RichEmbed) { - (this.mention) ? msg.reply('', answer) : msg.channel.send('', answer); - } else if (answer instanceof Promise) { - let resolvedAnswer = await answer; - await this.answerMessage(msg, resolvedAnswer); - } else if (answer instanceof Array) { - await waterfall(answer.map((x) => async () => await this.answerMessage(msg, x))); // execute each after another - } else if ({}.toString.call(answer) === '[object Function]') { - await this.answerMessage(msg, answer()); - } else if (answer) { - (this.mention) ? msg.reply(answer) : msg.channel.send(answer); - } - } - /** * Returns the guild handler by id, creates one if it doesn't exist and returns it then - * @param guild - * @param prefix + * @param guild {Guild} * @returns {*} */ - async getGuildHandler(guild, prefix) { + async getGuildHandler(guild) { if (!this.guildHandlers[guild.id]) { - let newGuildHandler = new guilding.GuildHandler(guild, prefix); + let newGuildHandler = new guilding.GuildHandler(guild); await newGuildHandler.initDatabase(); this.guildHandlers[guild.id] = newGuildHandler; } @@ -398,7 +246,8 @@ class Bot { // Executing the main function if (typeof require !== 'undefined' && require.main === module) { - logger.info("Starting up... "); // log the current date so that the logfile is better to read. + let logger = new logging.Logger('MAIN-init'); + logger.info("Starting up... "); logger.debug('Calling constructor...'); let discordBot = new Bot(); logger.debug('Initializing services...'); diff --git a/commands/globalcommands.json b/commands/globalcommands.json deleted file mode 100644 index 7ee7886..0000000 --- a/commands/globalcommands.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "utils": { - "help": { - "name": "help", - "permission": "all", - "description": "Shows this help command.", - "category": "Utility", - "args": [ - "command" - ] - }, - "say": { - "name": "say", - "permission": "all", - "description": "Says something. ~say [String].", - "category": "Utility" - }, - "addpresence": { - "name": "addpresence", - "permission": "owner", - "description": "Adds a presence to presences.", - "category": "Utility" - }, - "shutdown": { - "name": "shutdown", - "description": "Shuts the bot down.", - "permission": "owner", - "category": "Utility" - }, - "rotate": { - "name": "rotate", - "description": "Forces a presence rotation", - "permission": "owner", - "category": "Utility" - }, - "createUser": { - "name": "createUser", - "permission": "owner", - "description": "Creates a new user for the webinterface.", - "category": "Utility", - "args": [ - "username", - "password", - "scope" - ] - }, - "bugreport": { - "name": "bug", - "permission": "all", - "description": "Get info about how to report a bug", - "category": "Utility", - "response": { - "bug_report": "Please report your bugs [here](https://github.com/Trivernis/discordbot.js/issues)" - } - } - }, - "info": { - "about": { - "name": "about", - "permission": "all", - "description": "Shows information about this bot.", - "category": "Info", - "response": { - "about_icon": "This icon war created by [blackrose14344](https://www.deviantart.com/blackrose14344). \n [Original](https://www.deviantart.com/blackrose14344/art/2B-Chi-B-685771489)", - "about_creator": "This bot was created by Trivernis. More about this bot [here](https://github.com/Trivernis/discordbot.js)." - } - }, - "ping": { - "name": "ping", - "permission": "owner", - "description": "Answers with the current average ping of the bot.", - "category": "Info" - }, - "uptime": { - "name": "uptime", - "permission": "owner", - "description": "Answers with the current uptime of the bot.", - "category": "Info" - }, - "guilds": { - "name": "guilds", - "permission": "owner", - "description": "Answers with the number of guilds the bot has joined", - "category": "Info" - } - } -} diff --git a/commands/servercommands.json b/commands/servercommands.json deleted file mode 100644 index e83b393..0000000 --- a/commands/servercommands.json +++ /dev/null @@ -1,203 +0,0 @@ -{ - "utils": { - "roles": { - "name": "roles", - "permission": "all", - "description": "Shows the roles used for commands on the server.", - "category": "Utility" - }, - "savecmd": { - "name": "savecmd", - "permission": "moderator", - "description": "Saves a sequence of commands under a new name. ~save [cmdsequence] [cmdname]. Semicoli must be escaped with \\ (Backslash)", - "category": "Utility", - "response": { - "no_recursion": "You are **not** allowed to execute another saved command in this sequence. This is a safety measure to avoid endlessly recursive calls.", - "sequence_too_long": "This command sequence is too long!" - } - }, - "savedcmd": { - "name": "savedcmd", - "permission": "all", - "description": "Displays the saved commands.", - "category": "Utility", - "response": { - "no_commands": "There are no saved commands." - } - }, - "deletecmd": { - "name": "deletecmd", - "permission": "moderator", - "description": "Delete a saved command.", - "args": [ - "cmdname" - ], - "category": "Utility" - }, - "execute": { - "name": "execute", - "permission": "all", - "args": [ - "cmdname" - ], - "description": "Execute saved commands.", - "category": "Utility", - "response": { - "not_found": "This command could not be found." - } - } - }, - "music": { - "play": { - "name": "play", - "permission": "all", - "args": [ - "url" - ], - "description": "Adds the url to the YouTube video/playlist into the queue.", - "category": "Music", - "response": { - "success": "Added Song/Playlist to the queue.", - "failure": "Failed adding Song/Playlist to the queue.", - "url_invalid": "This is not a valid url!", - "no_url": "I need an url to a video to play!", - "no_voicechannel": "You need to join a voicechannel to do that!" - } - }, - "playnext": { - "name": "playnext", - "permission": "all", - "args": [ - "url" - ], - "description": "Adds the url to the YouTube video as next song to the queue.", - "category": "Music", - "response": { - "success": "Added Song as next Song to the queue.", - "failure": "Failed adding Song as next Song to the queue.", - "url_invalid": "This is not a valid url!", - "no_url": "I need an url to a video to play", - "no_voicechannel": "You need to join a voicechannel to do that!" - } - }, - "join": { - "name": "join", - "permission": "all", - "description": "Joins the VC you are in.", - "category": "Music", - "response": { - "not_connected": "You are not connected to a Voice Channel." - } - }, - "stop": { - "name": "stop", - "permission": "dj", - "description": "Stops playing music and leaves.", - "category": "Music", - "response": { - "success": "Stopping now...", - "not_playing": "I'm not playing music at the moment." - } - }, - "pause": { - "name": "pause", - "permission": "all", - "description": "Pauses playing.", - "category": "Music", - "response": { - "success": "Pausing playback.", - "not_playing": "I'm not playing music at the moment." - } - }, - "resume": { - "name": "resume", - "permission": "all", - "description": "Resumes playing.", - "category": "Music", - "response": { - "success": "Resuming playback.", - "not_playing": "I'm not playing music at the moment." - } - }, - "skip": { - "name": "skip", - "permission": "dj", - "description": "Skips the current song.", - "category": "Music", - "response": { - "success": "Skipping to the next song.", - "not_playing": "I'm not playing music at the moment." - } - }, - "clear": { - "name": "clear", - "permission": "dj", - "description": "Clears the queue.", - "category": "Music", - "response": { - "success": "The Queue has been cleared." - } - }, - "playlist": { - "name": "queue", - "permission": "all", - "description": "Shows the next ten songs.", - "category": "Music" - }, - "current": { - "name": "np", - "permission": "all", - "description": "Shows the currently playing song.", - "category": "Music", - "response": { - "not_playing": "I'm not playing music at the moment." - } - }, - "shuffle": { - "name": "shuffle", - "permission": "all", - "description": "Shuffles the playlist.", - "category": "Music", - "response": { - "success": "The Queue has been shuffled." - } - }, - "repeat": { - "name": "repeat", - "permission": "all", - "description": "Toggle listening on repeat.", - "category": "Music", - "response": { - "repeat_true": "Listening on repeat now!", - "repeat_false": "Not listening on repeat anymore." - } - }, - "savemedia": { - "name": "savemedia", - "permission": "dj", - "args": [ - "url" - ], - "description": "Saves the YouTube song/playlist with a specific name", - "category": "Music" - }, - "savedmedia": { - "name": "savedmedia", - "permission": "all", - "description": "Prints out all saved playlists and songs.", - "category": "Music", - "response": { - "no_saved": "There are no saved songs/playlists :(" - } - }, - "deletemedia": { - "name": "deletemedia", - "permission": "dj", - "description": "Deletes a saved media entry. ~deletemedia [name]", - "category": "Music", - "response": { - "no_name": "You must provide a name for the media that shall be deleted." - } - } - } -} diff --git a/lib/CommandLib.js b/lib/CommandLib.js new file mode 100644 index 0000000..527c794 --- /dev/null +++ b/lib/CommandLib.js @@ -0,0 +1,282 @@ +const Discord = require('discord.js'), + yaml = require('js-yaml'), + fsx = require('fs-extra'), + logging = require('./logging'), + config = require('../config.json'), + utils = require('./utils'); + +const scopes = { + 'Global': 0, + 'User': 1, + 'Guild': 2 +}; + +class Answer { + + /** + * Creates an new Answer object with _func as answer logic. + * @param func + */ + constructor(func) { + this._func = func; + } + + /** + * Evaluates the answer string for the answer object. + * If the logic function returns a promise all nested promises get resolved. + * @param message + * @param kwargs + * @param argsString + * @returns {Promise<*>} + */ + async evaluate(message, kwargs, argsString) { + let result = this._func(message, kwargs, argsString); + if (result instanceof Promise) + return await utils.resolveNestedPromise(result); + else + return result; + } +} + +class Command { + + /** + * Creates a new command object where the answer function needs + * to be implemented for it to work. + * @param template {JSON:{}} + * @param answer {Answer} + */ + constructor(template, answer) { + this.name = template.name; + this.prefix = ''; + this.description = template.description; + this.args = template.args || []; + this.permission = template.permission; + this.category = template.category || 'Other'; + this.usage = template.usage || + `${this.name} [${this.args.join('][')}]`.replace('[]', ''); + this.answObj = answer; + if (!template.name) + throw new Error("Template doesn't define a name."); + } + + /** + * This method is meant to be replaced by logic. + * @abstract + * @param message {Discord.Message} + * @param kwargs {JSON} + * @param argsString {String} The raw argument string. + * @returns {String} + */ + async answer(message, kwargs, argsString) { + return await this.answObj.evaluate(message, kwargs, argsString); + } + + /** + * Returns rich help embed for this command. + * @returns {*|Discord.RichEmbed} + */ + get help() { + return new ExtendedRichEmbed(`Help for ${this.name}`) + .addFields({ + 'Usage': `\`${this.prefix}${this.usage}\``, + 'Description': this.description, + 'Permission Role': this.permission + }); + } +} + +class CommandHandler { + + /** + * Initializes the CommandHandler + * @param prefix {String} The prefix of all commands. + * @param scope {Number} A scope from the CommandScopes (scopes) + */ + constructor(prefix, scope) { + this.prefix = prefix; + this.scope = scope; + this.commands = {}; + this._logger = new logging.Logger(`${this.constructor.name}@${Object.keys(scopes)[this.scope]}`); + } + + /** + * Handles the command and responds to the message. + * @param commandMessage {String} + * @param message {Discord.Message} + * @returns {Boolean | String | Promise} + */ + handleCommand(commandMessage, message) { + this._logger.debug(`Handling command ${commandMessage}`); + let commandName = commandMessage.match(/^\S+/); + if (commandName.length > 0) + commandName = commandName[0]; + this._logger.silly(`Command name is ${commandName}`); + if (commandName.indexOf(this.prefix) >= 0) { + commandName = commandName.replace(this.prefix, ''); + let argsString = commandMessage.replace(/^\S+/, ''); + argsString = argsString + .replace(/^\s+/, '') // leading whitespace + .replace(/\s+$/, ''); // trailing whitespace + let args = argsString.match(/\S+/g); + let command = this.commands[commandName]; + if (command && this._checkPermission(message, command.permission)) { + this._logger.silly(`Permission ${command.permission} granted for command ${commandName}`); + let kwargs = {}; + if (args) + for (let i = 0; i < Math.min(command.args.length, args.length); i++) + kwargs[command.args[i]] = args[i]; + return command.answer(message, kwargs, argsString); + } else if (command) { + this._logger.silly(`Permission ${command.permission} denied for command ${commandName}`); + return "You don't have permission for this command"; + } else { + this._logger.silly(`Command ${commandName} not found.`); + return false; + } + } else { + this._logger.silly(`No prefix found in command ${commandName}`); + return false; + } + } + + /** + * Registers the command so that the handler can use it. + * @param command {Command} + */ + registerCommand(command) { + command.prefix = this.prefix; + this.commands[command.name] = command; + this._logger.debug(`Registered ${command.name} on handler`); + return this; + } + + + /** + * Checks if the author of the message has the given permission + * @param msg {Discord.Message} + * @param rolePerm {String} Permission String + * @returns {boolean} + * @private + */ + _checkPermission(msg, rolePerm) { + if (!rolePerm || ['all', 'any', 'everyone'].includes(rolePerm)) + return true; + if (config.owners.includes(msg.author.tag)) + return true; + else if (msg.member && rolePerm && rolePerm !== 'owner' && msg.member.roles + .some(role => (role.name.toLowerCase() === rolePerm.toLowerCase() || + role.name.toLowerCase() === 'botcommander'))) + return true; + return false; + } +} + +/** + * @abstract + */ +class CommandModule { + + /** + * Initializes a CommandModule instance. + * @param scope + */ + constructor(scope) { + this.scope = scope; + this._logger = new logging.Logger(this); + } + + /** + * Loads a template for the object property templateFile or the given argument file. + * @returns {Promise} + * @private + */ + async _loadTemplate(file) { + let templateString = await fsx.readFile(this.templateFile || file, {encoding: 'utf-8'}); + this._logger.silly(`Loaded Template file ${this.templateFile || file}`); + this.template = yaml.safeLoad(templateString); + } + + /** + * Registering commands after loading a template + * @param commandHandler {CommandHandler} + * @returns {Promise} + */ + async register(commandHandler) { // eslint-disable-line no-unused-vars + + } +} + +class ExtendedRichEmbed extends Discord.RichEmbed { + + /** + * Constructor that automatically set's the Title and Timestamp. + * @param title {String} + */ + constructor(title) { + super(); + this.setTitle(title); + this.setTimestamp(); + } + + /** + * Adds a Field when a name is given or adds a blank Field otherwise + * @param name {String} + * @param content {String} + * @returns {ExtendedRichEmbed} + */ + addNonemptyField(name, content) { + if (name && name.length > 0 && content && content.length > 0) + this.addField(name, content); + return this; + } + + /** + * Adds the fields defined in the fields JSON + * @param fields {JSON} + * @returns {ExtendedRichEmbed} + */ + addFields(fields) { + for (let [name, value] of Object.entries(fields)) + this.addNonemptyField(name, value); + return this; + } + + /** + * Sets the description by shortening the value string to a fitting length for discord. + * @param value + */ + setDescription(value) { + let croppedValue = value; + if (value.substring) + croppedValue = value.substring(0, 1024); + if (croppedValue.length < value.length) + croppedValue = croppedValue.replace(/\n.*$/g, ''); + super.setDescription(croppedValue); + } + + /** + * Sets the field by shortening the value stirn to a fitting length for discord. + * @param name + * @param value + */ + addField(name, value) { + let croppedValue = value; + if (value.substring) + croppedValue = value.substring(0, 1024); + if (croppedValue.length < value.length) + croppedValue = croppedValue.replace(/\n.*$/g, ''); + super.addField(name, croppedValue); + } +} + +// -- exports -- // + +Object.assign(exports, { + Answer: Answer, + Command: Command, + CommandHandler: CommandHandler, + CommandModule: CommandModule, + ExtendedRichEmbed: ExtendedRichEmbed, + CommandScopes: scopes +}); diff --git a/lib/MessageLib.js b/lib/MessageLib.js new file mode 100644 index 0000000..3642b43 --- /dev/null +++ b/lib/MessageLib.js @@ -0,0 +1,201 @@ +const cmdLib = require('./CommandLib'), + config = require('../config.json'), + Discord = require('discord.js'), + logging = require('./logging'), + promiseWaterfall = require('promise-waterfall'); + +/* eslint no-useless-escape: 0 */ + +class MessageHandler { + + /** + * Message Handler to handle messages. Listens on the + * _client message event. + * @param client {Discord.Client} + */ + constructor (client) { + this.discordClient = client; + this.logger = new logging.Logger(this); + this.globalCmdHandler = new cmdLib.CommandHandler(config.prefix, + cmdLib.CommandScopes.Global); + this.userCmdHandler = new cmdLib.CommandHandler(config.prefix, + cmdLib.CommandScopes.User); + this.guildCmdHandler = new cmdLib.CommandHandler(config.prefix, + cmdLib.CommandScopes.Guild); + this.userRates = {}; + this._registerEvents(); + } + + /** + * Returns the handler fitting the scope + * @param scope {Number} + * @returns {cmdLib.CommandHandler} + */ + getHandler(scope) { + switch (scope) { + case cmdLib.CommandScopes.Global: + return this.globalCmdHandler; + case cmdLib.CommandScopes.Guild: + return this.guildCmdHandler; + case cmdLib.CommandScopes.User: + return this.userCmdHandler; + } + } + + /** + * Registers a command module to a command handler. + * @param CommandModule {cmdLib.CommandModule} + * @param options {Object} Options passed to the module constructor. + * @returns {Promise} + */ + async registerCommandModule(CommandModule, options) { + this.logger.info(`Registering command module ${CommandModule.name}...`); + let cmdModule = new CommandModule(options); + await cmdModule.register(this.getHandler(cmdModule.scope)); + } + + /** + * Parses a string to a command sequence Array. + * Workaround to not reveal the private parseSyntax function. + * @param synStr {String} + */ + parseSyntaxString(synStr) { + return this._parseSyntax({content: synStr}); + } + + /** + * Registering event handlers. + * @private + */ + _registerEvents() { + this.logger.debug('Registering message event...'); + this.discordClient.on('message', async (msg) => { + this.logger.verbose(`<${msg.guild? msg.channel.name+'@'+msg.guild.name : 'PRIVATE'}> ${msg.author.tag}: ${msg.content}`); + if (msg.author !== this.discordClient.user + && this._checkPrefixStart(msg.content) + && !this._checkRateReached(msg.author)) { + + let sequence = this._parseSyntax(msg); + this.logger.debug(`Syntax parsing returned: ${JSON.stringify(sequence)}`); + await this.executeCommandSequence(sequence, msg); + this.logger.debug('Executed command sequence'); + } + }); + } + + /** + * Parses the syntax of a message into a command array. + * @param message + * @returns {Array>} + * @private + */ + _parseSyntax(message) { + this.logger.silly('Parsing command sequence...'); + let commandSequence = []; + let content = message.content; + let strings = content.match(/".+?"/g) || []; + + for (let string of strings) + content = content.replace(string, string // escape all special chars + .replace(/;/g, '\\;') + .replace(/&/g, '\\&')); + let independentCommands = content // independent command sequende with ; + .split(/(? x.replace(/^ +/, '')); + for (let indepCommand of independentCommands) + commandSequence.push(indepCommand + .split(/(? x.replace(/^ +/, '')) + ); + return commandSequence; + } + + /** + * Executes a sequence of commands + */ + async executeCommandSequence(cmdSequence, message) { + this.logger.silly(`Executing command sequence: ${JSON.stringify(cmdSequence)} ...`); + let scopeCmdHandler = this.getScopeHandler(message); + await Promise.all(cmdSequence.map((sq) => promiseWaterfall(sq.map((cmd) => async () => { + try { + this.logger.silly(`Executing command ${cmd}`); + let globalResult = await this.globalCmdHandler.handleCommand(cmd, message); + let scopeResult = await scopeCmdHandler.handleCommand(cmd, message); + this.logger.silly(`globalResult: ${globalResult}, scopeResult: ${scopeResult}`); + + if (scopeResult) + this._answerMessage(message, scopeResult); + else if (globalResult) + this._answerMessage(message, globalResult); + } catch (err) { + this.logger.verbose(err.message); + this.logger.silly(err.stack); + } + })))); + } + + /** + * Returns two commandHandlers for the messages scope. + * @param message + * @private + */ + getScopeHandler(message) { + if (message && message.guild) + return this.guildCmdHandler; + else + return this.userCmdHandler; + } + + /** + * Answers + * @param message {Discord.Message} + * @param answer {String | Discord.RichEmbed} + * @private + */ + _answerMessage(message, answer) { + this.logger.debug(`Sending answer ${answer}`); + if (answer) + if (answer instanceof Discord.RichEmbed) + message.channel.send('', answer); + else + message.channel.send(answer); + } + + /** + * Checks if the messageString beginns with a command prefix. + * @param msgString {String} + * @private + */ + _checkPrefixStart(msgString) { + let p1 = this.globalCmdHandler.prefix; + let p2 = this.guildCmdHandler.prefix; + let p3 = this.userCmdHandler.prefix; + return ( + new RegExp(`^\\s*?${p1}`).test(msgString) || + new RegExp(`^\\s*?${p2}`).test(msgString) || + new RegExp(`^\\s*?${p3}`).test(msgString)); + } + + /** + * Checks if the user has reached the command rate limit and updates it. + * @param user {Discord.User} + * @returns {boolean} + * @private + */ + _checkRateReached(user) { + if (!this.userRates[user.tag]) + this.userRates[user.tag] = {last: 0, count: 0}; + let userEntry = this.userRates[user.tag]; + let reached = ((Date.now() - userEntry.last)/1000) < (config.rateLimitTime || 10) + && userEntry.count > (config.rateLimitCount || 5); + if (((Date.now() - userEntry.last)/1000) > (config.rateLimitTime || 10)) + this.userRates[user.tag].count = 0; + this.userRates[user.tag].last = Date.now(); + this.userRates[user.tag].count++; + return reached; + } +} + +Object.assign(exports, { + MessageHandler: MessageHandler +}); diff --git a/lib/music.js b/lib/MusicLib.js similarity index 75% rename from lib/music.js rename to lib/MusicLib.js index 0f77880..d6558da 100644 --- a/lib/music.js +++ b/lib/MusicLib.js @@ -1,20 +1,16 @@ const ytdl = require("ytdl-core"), ypi = require('youtube-playlist-info'), yttl = require('get-youtube-title'), - args = require('args-parser')(process.argv), config = require('../config.json'), utils = require('./utils.js'), - ytapiKey = args.ytapi || config.api.youTubeApiKey; -/* Variable Definition */ -let logger = require('winston'); + logging = require('./logging'), + ytapiKey = config.api.youTubeApiKey; -/* Function Definition */ - -exports.setLogger = function (newLogger) { - logger = newLogger; -}; - -exports.DJ = class { +/** + * The Music Player class is used to handle music playing tasks on Discord Servers (Guilds). + * @type {MusicPlayer} + */ +class MusicPlayer { constructor(voiceChannel) { this.conn = null; this.disp = null; @@ -26,6 +22,8 @@ exports.DJ = class { this.voiceChannel = voiceChannel; this.quality = 'lowest'; this.exitTimeout = null; + this._logger = new logging.Logger(this); + this._logger.silly('Initialized Music Player'); } /** @@ -43,21 +41,20 @@ exports.DJ = class { } else if (voiceChannel) this.voiceChannel = voiceChannel; - logger.verbose(`Connecting to voiceChannel ${this.voiceChannel.name}`); + this._logger.verbose(`Connecting to voiceChannel ${this.voiceChannel.name}`); let connection = await this.voiceChannel.join(); - logger.info(`Connected to Voicechannel ${this.voiceChannel.name}`); + this._logger.info(`Connected to Voicechannel ${this.voiceChannel.name}`); this.conn = connection; } /** * Defining setter for listenOnRepeat to include the current song into the repeating loop. - * @param value + * @param value {Boolean} */ set listenOnRepeat(value) { this.repeat = value; if (this.current) this.queue.push(this.current); - } /** @@ -74,19 +71,19 @@ exports.DJ = class { /** * Updates the channel e.g. when the bot is moved to another channel. - * @param voiceChannel + * @param voiceChannel {Discord.VoiceChannel} */ updateChannel(voiceChannel) { if (voiceChannel) { this.voiceChannel = voiceChannel; - logger.debug(`Updated voiceChannel to ${this.voiceChannel.name}`); + this._logger.debug(`Updated voiceChannel to ${this.voiceChannel.name}`); } } /** * Plays a file for the given filename. * TODO: Implement queue - * @param filename + * @param filename {String} * @todo */ playFile(filename) { @@ -94,7 +91,7 @@ exports.DJ = class { this.disp = this.conn.playFile(filename); this.playing = true; } else { - logger.warn("Not connected to a voicechannel. Connection now."); + this._logger.warn("Not connected to a voicechannel. Connection now."); this.connect(this.voiceChannel).then(() => { this.playFile(filename); }); @@ -109,13 +106,13 @@ exports.DJ = class { if (this.exitTimeout) { clearTimeout(this.exitTimeout); this.exitTimeout = null; - logger.debug(`Cleared exit timout for ${this.voiceChannel.name}`); + this._logger.debug(`Cleared exit timout for ${this.voiceChannel.name}`); } if (this.connected && this.voiceChannel.members.size === 1) { - logger.debug(`Set exit timout for ${this.voiceChannel.name}`); + this._logger.debug(`Set exit timout for ${this.voiceChannel.name}`); this.exitTimeout = setTimeout(() => { if (this.connected && this.voiceChannel.members.size === 1) - logger.verbose(`Exiting ${this.voiceChannel.name}`); + this._logger.verbose(`Exiting ${this.voiceChannel.name}`); this.stop(); }, config.music.timeout || 300000); } @@ -125,13 +122,13 @@ exports.DJ = class { * Plays the url of the current song if there is no song playing or puts it in the queue. * If the url is a playlist, the videos of the playlist are fetched and put * in the queue. For each song the title is saved in the queue too. - * @param url - * @param playnext + * @param url {String} + * @param playnext {Boolean} */ async playYouTube(url, playnext) { let plist = utils.YouTube.getPlaylistIdFromUrl(url); if (plist) { - logger.debug(`Adding playlist ${plist} to queue`); + this._logger.debug(`Adding playlist ${plist} to queue`); let playlistItems = await ypi(ytapiKey, plist); let firstSong = utils.YouTube.getVideoUrlFromId(playlistItems.shift().resourceId.videoId); let firstSongTitle = null; @@ -139,14 +136,14 @@ exports.DJ = class { firstSongTitle = await this.getVideoName(firstSong); } catch(err) { if (err.message !== 'Not found') { - logger.warn(err.message); - logger.debug(err.stack); + this._logger.warn(err.message); + this._logger.debug(err.stack); } } if (this.repeat) this.queue.push({'url': firstSong, 'title': firstSongTitle}); - this.playYouTube(firstSong).catch((err) => logger.warn(err.message)); + this.playYouTube(firstSong).catch((err) => this._logger.warn(err.message)); for (let item of playlistItems) { let vurl = utils.YouTube.getVideoUrlFromId(item.resourceId.videoId); @@ -154,14 +151,14 @@ exports.DJ = class { this.queue.push({'url': vurl, 'title': await this.getVideoName(vurl)}); //eslint-disable-line no-await-in-loop } catch (err) { if (err.message !== 'Not found') { - logger.warn(err.message); - logger.debug(err.stack); + this._logger.warn(err.message); + this._logger.debug(err.stack); } } } - logger.debug(`Added ${playlistItems.length} songs to the queue`); + this._logger.debug(`Added ${playlistItems.length} songs to the queue`); } else if (!this.playing || !this.disp) { - logger.debug(`Playing ${url}`); + this._logger.debug(`Playing ${url}`); this.current = ({'url': url, 'title': await this.getVideoName(url)}); this.disp = this.conn.playStream(ytdl(url, @@ -176,7 +173,7 @@ exports.DJ = class { this.current = this.queue.shift(); if (this.repeat) // listen on repeat this.queue.push(this.current); - this.playYouTube(this.current.url).catch((err) => logger.warn(err.message)); + this.playYouTube(this.current.url).catch((err) => this._logger.warn(err.message)); } else { this.stop(); } @@ -184,7 +181,7 @@ exports.DJ = class { }); this.playing = true; } else { - logger.debug(`Added ${url} to the queue`); + this._logger.debug(`Added ${url} to the queue`); if (playnext) this.queue.unshift({'url': url, 'title': await this.getVideoName(url)}); else @@ -195,14 +192,14 @@ exports.DJ = class { /** * Gets the name of the YouTube Video at url - * @param url + * @param url {String} * @returns {Promise<>} */ getVideoName(url) { return new Promise((resolve, reject) => { yttl(utils.YouTube.getVideoIdFromUrl(url), (err, title) => { if (err) { - logger.debug(JSON.stringify(err)); + this._logger.debug(JSON.stringify(err)); reject(err); } else { resolve(title); @@ -213,15 +210,15 @@ exports.DJ = class { /** * Sets the volume of the dispatcher to the given value - * @param percentage + * @param percentage {Number} */ setVolume(percentage) { - logger.verbose(`Setting volume to ${percentage}`); + this._logger.verbose(`Setting volume to ${percentage}`); if (this.disp !== null) { this.volume = percentage; this.disp.setVolume(percentage); } else { - logger.warn("No dispatcher found."); + this._logger.warn("No dispatcher found."); } } @@ -229,11 +226,11 @@ exports.DJ = class { * Pauses if a dispatcher exists */ pause() { - logger.verbose("Pausing music..."); + this._logger.verbose("Pausing music..."); if (this.disp !== null) this.disp.pause(); else - logger.warn("No dispatcher found"); + this._logger.warn("No dispatcher found"); } @@ -241,11 +238,11 @@ exports.DJ = class { * Resumes if a dispatcher exists */ resume() { - logger.verbose("Resuming music..."); + this._logger.verbose("Resuming music..."); if (this.disp !== null) this.disp.resume(); else - logger.warn("No dispatcher found"); + this._logger.warn("No dispatcher found"); } @@ -257,25 +254,25 @@ exports.DJ = class { this.playing = false; this.queue = []; this.current = null; - logger.verbose("Stopping music..."); + this._logger.verbose("Stopping music..."); try { if (this.disp) { this.disp.end('stop'); this.disp = null; - logger.debug("Ended dispatcher"); + this._logger.debug("Ended dispatcher"); } if (this.conn) { this.conn.disconnect(); this.conn = null; - logger.debug("Ended connection"); + this._logger.debug("Ended connection"); } if (this.voiceChannel) { this.voiceChannel.leave(); - logger.debug("Left VoiceChannel"); - logger.info(`Disconnected from Voicechannel ${this.voiceChannel.name}`); + this._logger.debug("Left VoiceChannel"); + this._logger.info(`Disconnected from Voicechannel ${this.voiceChannel.name}`); } } catch (error) { - logger.verbose(JSON.stringify(error)); + this._logger.verbose(JSON.stringify(error)); } } @@ -285,7 +282,7 @@ exports.DJ = class { * It tries to play the next song with playYouTube */ skip() { - logger.debug("Skipping song"); + this._logger.debug("Skipping song"); if (this.disp !== null) { this.disp.end(); } else { @@ -293,8 +290,8 @@ exports.DJ = class { if (this.queue.length > 0) { this.current = this.queue.shift(); this.playYouTube(this.current.url).catch((err) => { - logger.error(err.message); - logger.debug(err.stack); + this._logger.error(err.message); + this._logger.debug(err.stack); }); } else { this.stop(); @@ -323,4 +320,8 @@ exports.DJ = class { clear() { this.queue = []; } -}; +} + +Object.assign(exports, { + MusicPlayer: MusicPlayer +}); diff --git a/lib/weblib.js b/lib/WebLib.js similarity index 87% rename from lib/weblib.js rename to lib/WebLib.js index 6704709..6bbcc96 100644 --- a/lib/weblib.js +++ b/lib/WebLib.js @@ -4,6 +4,7 @@ const express = require('express'), compression = require('compression'), md5 = require('js-md5'), sha512 = require('js-sha512'), + logging = require('./logging'), fs = require('fs'), session = require('express-session'), SQLiteStore = require('connect-sqlite3')(session), @@ -12,21 +13,19 @@ const express = require('express'), config = require('../config.json'), utils = require('../lib/utils'); -let logger = require('winston'); - -exports.setLogger = function (newLogger) { - logger = newLogger; -}; - exports.WebServer = class { constructor(port) { this.app = express(); this.server = null; this.port = port; - this.schema = buildSchema(fs.readFileSync('./web/graphql/schema.graphql', 'utf-8')); + this.schema = buildSchema(fs.readFileSync('./lib/api/graphql/schema.gql', 'utf-8')); this.root = {}; + this._logger = new logging.Logger(this); } + /** + * Configures express by setting properties and middleware. + */ configureExpress() { this.app.set('view engine', 'pug'); this.app.set('trust proxy', 1); @@ -38,7 +37,7 @@ exports.WebServer = class { this.app.use(require('cors')()); this.app.use(session({ store: new SQLiteStore({dir: './data', db: 'sessions.db'}), - secret: config.webservice.sessionSecret, + secret: config.webinterface.sessionSecret, resave: false, saveUninitialized: true, cookie: {secure: 'auto'}, @@ -65,7 +64,7 @@ exports.WebServer = class { } else { let user = await this.maindb.get('SELECT * FROM users WHERE username = ? AND password = ?', [req.body.username, req.body.password]); if (!user) { - logger.debug(`User ${req.body.username} failed to authenticate`); + this._logger.debug(`User ${req.body.username} failed to authenticate`); res.render('login', {msg: 'Login failed!'}); } else { req.session.user = user; @@ -86,7 +85,7 @@ exports.WebServer = class { this.app.use('/graphql', graphqlHTTP({ schema: this.schema, rootValue: this.root, - graphiql: config.webservice.graphiql || false + graphiql: config.webinterface.graphiql || false })); } @@ -95,19 +94,19 @@ exports.WebServer = class { */ start() { this.configureExpress(); - if (config.webservice.https && config.webservice.https.enabled) { + if (config.webinterface.https && config.webinterface.https.enabled) { let sslKey = null; let sslCert = null; - if (config.webservice.https.keyFile) - sslKey = fs.readFileSync(config.webservice.https.keyFile, 'utf-8'); - if (config.webservice.https.certFile) - sslCert = fs.readFileSync(config.webservice.https.certFile, 'utf-8'); + if (config.webinterface.https.keyFile) + sslKey = fs.readFileSync(config.webinterface.https.keyFile, 'utf-8'); + if (config.webinterface.https.certFile) + sslCert = fs.readFileSync(config.webinterface.https.certFile, 'utf-8'); if (sslKey && sslCert) { - logger.verbose('Creating https server.'); + this._logger.verbose('Creating https server.'); this.server = require('https').createServer({key: sslKey, cert: sslCert}, this.app); } else { - logger.warn('Key or certificate file not found. Fallback to http server.'); + this._logger.warn('Key or certificate file not found. Fallback to http server.'); this.server = require('http').createServer(this.app); } } else { @@ -172,7 +171,7 @@ exports.WebServer = class { .slice(args.offset, args.offset + args.first) .map(async (x) => new Guild(x, await objects.getGuildHandler(x)))); } catch (err) { - logger.error(err.stack); + this._logger.error(err.stack); return null; } @@ -197,8 +196,8 @@ exports.WebServer = class { return dcGuilds.filter((x) => { let gh = objects.guildHandlers[x.id]; if (gh) - if (gh.dj) - return gh.dj.playing; + if (gh.musicPlayer) + return gh.musicPlayer.playing; else return false; else @@ -269,16 +268,16 @@ function generateUUID(input) { } /** - * Used for graphql attribute access to the lib/music/DJ + * Used for graphql attribute access to the lib/music/MusicPlayer */ -class DJ { - constructor(musicDj) { - this.dj = musicDj; - this.quality = musicDj.quality; +class MusicPlayer { + constructor(musicPlayer) { + this.musicPlayer = musicPlayer; + this.quality = musicPlayer.quality; } queue(args) { - let queue = this.dj.queue.map((x) => { + let queue = this.musicPlayer.queue.map((x) => { return { id: generateID(['Media', x.url]), name: x.title, @@ -294,35 +293,35 @@ class DJ { } get playing() { - return this.dj.playing; + return this.musicPlayer.playing; } get connected() { - return this.dj.connected; + return this.musicPlayer.connected; } get paused() { - return this.dj.disp? this.dj.disp.paused : false; + return this.musicPlayer.disp? this.musicPlayer.disp.paused : false; } get queueCount() { - return this.dj.queue.length; + return this.musicPlayer.queue.length; } get songStartTime() { - return this.dj.disp.player.streamingData.startTime; + return this.musicPlayer.disp.player.streamingData.startTime; } get volume() { - return this.dj.volume; + return this.musicPlayer.volume; } get repeat() { - return this.dj.repeat; + return this.musicPlayer.repeat; } get currentSong() { - let x = this.dj.current; + let x = this.musicPlayer.current; return { id: generateID(['Media', x.url]), name: x.title, @@ -332,7 +331,7 @@ class DJ { } get voiceChannel() { - return this.dj.voiceChannel.name; + return this.musicPlayer.voiceChannel.name; } } @@ -355,7 +354,7 @@ class Guild { this.ready = guildHandler.ready; this.prSaved = null; this.guildHandler = guildHandler; - this.dj = this.guildHandler.dj ? new DJ(this.guildHandler.dj) : null; + this.musicPlayer = this.guildHandler.musicPlayer ? new MusicPlayer(this.guildHandler.musicPlayer) : null; } async querySaved() { @@ -473,5 +472,6 @@ class LogEntry { this.message = entry.message; this.timestamp = entry.timestamp; this.level = entry.level; + this.module = entry.module || entry.m || 'DEFAULT'; } } diff --git a/lib/api/AnilistApi.js b/lib/api/AnilistApi.js new file mode 100644 index 0000000..e18efc4 --- /dev/null +++ b/lib/api/AnilistApi.js @@ -0,0 +1,177 @@ +const fetch = require('node-fetch'), + fsx = require('fs-extra'), + yaml = require('js-yaml'), + queryPath = './lib/api/graphql/AnilistApi', + alApiEndpoint = 'https://graphql.anilist.co'; + +async function getFragments() { + let fragments = await fsx.readFile(`${queryPath}/Fragments.yaml`, {encoding: 'utf-8'}); + return yaml.safeLoad(fragments); +} + +/** + * Return a graphql query read from a file from a configured path. + * @param name + * @returns {Promise} + */ +async function getGraphqlQuery(name) { + let query = await fsx.readFile(`${queryPath}/${name}.gql`, {encoding: 'utf-8'}); + let fragments = await getFragments(); + for (let [key, value] of Object.entries(fragments)) + if (query.includes(`...${key}`)) + query += '\n' + value; + return query; +} + +/** + * Post a query read from a file to the configured graphql endpoint and return the data. + * @param queryName + * @param queryVariables + * @returns {Promise} + */ +function postGraphqlQuery(queryName, queryVariables) { + return new Promise(async (resolve, reject) => { + fetch(alApiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + query: (await getGraphqlQuery(queryName)), + variables: queryVariables + }) + }).then(async (response) => { + let json = await response.json(); + return response.ok ? json: Promise.reject(json); + }).then((data) => resolve(data.data)).catch((err) => reject(err)); + }); +} + +/** + * Get an anime by id. + * @param id {Number} + * @param withStaff {Boolean} Include Staff information? + * @param withMetadata {Boolean} Include Metadata? + * @returns {Promise} + */ +async function getAnimeById(id, withStaff, withMoreData) { + let data = await postGraphqlQuery('AnimeQuery', + {id: id, withStaff: withStaff, withMoreData: withMoreData}); + if (data && data.Media) + return data.Media; + else + return null; +} + +/** + * Get a manga by id. + * @param id {Number} + * @param withStaff {Boolean} Include Staff information? + * @param withMoreData {Boolean} Include Metadata? + * @returns {Promise} + */ +async function getMangaById(id, withStaff, withMoreData) { + let data = await postGraphqlQuery('MangaQuery', + {id: id, withStaff: withStaff, withMoreData: withMoreData}); + if (data && data.Media) + return data.Media; + else + return null; +} + +/** + * Returns a staff member by id. + * @param id {Number} + * @returns {Promise<*>} + */ +async function getStaffById(id) { + let data = await postGraphqlQuery('StaffQuery', {id: id}); + if (data && data.Staff) + return data.Staff; + else + return null; +} + +/** + * Returns a character by id. + * @param id {Number} + * @returns {Promise<*>} + */ +async function getCharacterById(id) { + let data = await postGraphqlQuery('CharacterQuery', {id: id}); + if (data && data.Character) + return data.Character; + else + return null; +} + +/** + * Search for an anime by name and get it by id. + * @param name {String} + * @param withStaff {Boolean} Include Staff information? + * @param withMoreData {Boolean} Include Metadata? + * @returns {Promise<*>} + */ +async function searchAnimeByName(name, withStaff, withMoreData) { + let data = await postGraphqlQuery('AnimeQuery', + {name: name, withStaff: withStaff, withMoreData: withMoreData}); + if (data && data.Media) + return data.Media; + else + return null; +} + +/** + * Search for a manga by name and get it by id. + * @param name {String} + * @param withStaff {Boolean} Include Staff information? + * @param withMoreData {Boolean} Include Metadata? + * @returns {Promise<*>} + */ +async function searchMangaByName(name, withStaff, withMoreData) { + let data = await postGraphqlQuery('MangaQuery', + {name: name, withStaff: withStaff, withMoreData: withMoreData}); + if (data && data.Media) + return data.Media; + else + return null; +} + +/** + * Search for a staff member by name and get information. + * @param name {String} The name of the staff member + * @returns {Promise<*>} + */ +async function searchStaffByName(name) { + let data = await postGraphqlQuery('StaffQuery', {name: name}); + if (data && data.Staff) + return data.Staff; + else + return null; +} + +/** + * Seach for a character by name and get information. + * @param name {String} Character Name + * @returns {Promise<*>} + */ +async function searchCharacterByName(name) { + let data = await postGraphqlQuery('CharacterQuery', {name: name}); + if (data && data.Character) + return data.Character; + else + return null; +} + +// exports +Object.assign(exports, { + getAnimeById: getAnimeById, + getMangaById: getMangaById, + getStaffById: getStaffById, + getCharacterById: getCharacterById, + searchAnimeByName: searchAnimeByName, + searchMangaByName: searchMangaByName, + searchStaffByName: searchStaffByName, + searchCharacterByName: searchCharacterByName +}); diff --git a/lib/api/graphql/AnilistApi/AnimeQuery.gql b/lib/api/graphql/AnilistApi/AnimeQuery.gql new file mode 100644 index 0000000..b0fd9cd --- /dev/null +++ b/lib/api/graphql/AnilistApi/AnimeQuery.gql @@ -0,0 +1,22 @@ +query AnimeData($name: String, $id: Int, $withStaff: Boolean = false, $withMoreData: Boolean = true) { + Media (id: $id, search: $name, type: ANIME) { + ...mediaMetadata + ...mediaAdditionalMetadata @include(if: $withMoreData) + ...staffFields @include(if: $withStaff) + season @include(if: $withMoreData) + episodes @include(if: $withMoreData) + duration @include(if: $withMoreData) + studios(isMain: true) @include(if: $withMoreData) { + studioList: nodes { + id + name + siteUrl + } + } + nextAiringEpisode @include(if: $withMoreData) { + id + airingAt + episode + } + } +} diff --git a/lib/api/graphql/AnilistApi/CharacterQuery.gql b/lib/api/graphql/AnilistApi/CharacterQuery.gql new file mode 100644 index 0000000..a55bf3a --- /dev/null +++ b/lib/api/graphql/AnilistApi/CharacterQuery.gql @@ -0,0 +1,27 @@ +query ($name: String, $id: Int) { + Character(search: $name, id: $id) { + id + name { + first + last + native + } + description + image { + large + medium + } + siteUrl + media { + edges { + characterRole + voiceActors(language: JAPANESE) { + ...staffMetadata + } + node { + ...mediaMetadata + } + } + } + } +} diff --git a/lib/api/graphql/AnilistApi/Fragments.yaml b/lib/api/graphql/AnilistApi/Fragments.yaml new file mode 100644 index 0000000..ed844da --- /dev/null +++ b/lib/api/graphql/AnilistApi/Fragments.yaml @@ -0,0 +1,63 @@ +mediaMetadata: | + fragment mediaMetadata on Media { + id + siteUrl + title { + romaji + english + native + } + coverImage { + large + medium + color + } + } + +mediaAdditionalMetadata: | + fragment mediaAdditionalMetadata on Media { + status + description(asHtml: false) + format + genres + averageScore + favourites + startDate { + year + month + day + } + endDate { + year + month + day + } + } + +staffFields: | + fragment staffFields on Media { + staff { + edges { + node { + ...staffMetadata + } + role + } + } + } + +staffMetadata: | + fragment staffMetadata on Staff { + id + name { + first + last + native + } + image { + large + medium + } + language + siteUrl + } diff --git a/lib/api/graphql/AnilistApi/MangaQuery.gql b/lib/api/graphql/AnilistApi/MangaQuery.gql new file mode 100644 index 0000000..64cf9ae --- /dev/null +++ b/lib/api/graphql/AnilistApi/MangaQuery.gql @@ -0,0 +1,9 @@ +query MangaData($name: String, $id: Int, $withStaff: Boolean = false, $withMoreData: Boolean = true) { + Media (id: $id, search: $name, type: MANGA) { + ...mediaMetadata + ...mediaAdditionalMetadata @include(if: $withMoreData) + ...staffFields @include(if: $withStaff) + chapters @include(if: $withMoreData) + volumes @include(if: $withMoreData) + } +} diff --git a/lib/api/graphql/AnilistApi/StaffQuery.gql b/lib/api/graphql/AnilistApi/StaffQuery.gql new file mode 100644 index 0000000..ace6ba6 --- /dev/null +++ b/lib/api/graphql/AnilistApi/StaffQuery.gql @@ -0,0 +1,57 @@ +query StaffData($name: String, $id: Int) { + Staff(id: $id, search: $name) { + id + name { + first + last + native + } + language + image { + large + medium + } + staffMedia(page: 0, perPage: 10) { + edges { + node { + id + title { + romaji + english + native + } + siteUrl + } + characters { + id + name { + first + last + } + siteUrl + image { + large + medium + } + } + staffRole + } + } + characters(page: 0, perPage: 10) { + nodes { + id + name { + first + last + } + siteUrl + image { + large + medium + } + } + } + description(asHtml: false) + siteUrl + } +} diff --git a/web/graphql/schema.graphql b/lib/api/graphql/schema.gql similarity index 96% rename from web/graphql/schema.graphql rename to lib/api/graphql/schema.gql index bed89c0..bd14138 100644 --- a/web/graphql/schema.graphql +++ b/lib/api/graphql/schema.gql @@ -26,7 +26,7 @@ type GuildMember { roles(first: Int = 10, offset: Int = 0, id: String): [Role] highestRole: Role } -type DJ { +type MusicPlayer { queue(first: Int = 10, offset: Int = 0, id: String): [MediaEntry] queueCount: Int! songStartTime: String @@ -44,7 +44,7 @@ type Guild { discordId: String name: String owner: GuildMember - dj: DJ + musicPlayer: MusicPlayer members(first: Int = 10, offset: Int = 0, id: String): [GuildMember] memberCount: Int! roles(first: Int = 10, offset: Int = 0, id: String): [Role] @@ -73,6 +73,7 @@ type LogEntry { message: String level: String timestamp: String + module: String } type Query { client: Client diff --git a/lib/cmd.js b/lib/cmd.js deleted file mode 100644 index a6dd546..0000000 --- a/lib/cmd.js +++ /dev/null @@ -1,325 +0,0 @@ -/* Module definition */ - -/* Variable Definition */ -const Discord = require('discord.js'), - args = require('args-parser')(process.argv), - config = require('../config.json'), - gcmdTempl = require('../commands/globalcommands'), - scmdTempl = require('../commands/servercommands'); - -let logger = require('winston'), - globCommands = {}; - -/** - * @type {Servant} - */ -exports.Servant = class { - constructor(prefix) { - this.commands = {}; - this.prefix = prefix; - // show all commands (except the owner commands if the user is not an owner) - this.createCommand(gcmdTempl.utils.help, (msg, kwargs) => { - if (kwargs.command) { - let cmd = kwargs.command; - let allCommands = {...globCommands, ...this.commands}; - if (cmd.charAt(0) !== prefix) - cmd = this.prefix + cmd; - if (allCommands[cmd]) - return new Discord.RichEmbed() - .setTitle(`Help for ${cmd}`) - .addField('Usage', `\`${cmd} [${allCommands[cmd].args.join('] [')}]\``.replace('[]', '')) - .addField('Description', allCommands[cmd].description) - .addField('Permission Role', allCommands[cmd].role || 'all'); - else - return 'Command not found :('; - - } else { - let helpEmbed = new Discord.RichEmbed() - .setTitle('Commands') - .setDescription('Create a sequence of commands with `;` (semicolon).') - .setTimestamp(); - let globHelp = ''; - Object.entries(globCommands).sort().forEach(([key, value]) => { - if (value.role !== 'owner' || checkPermission(msg, 'owner')) - globHelp += `\`${key}\` \t`; - - }); - helpEmbed.addField('Global Commands', globHelp); - let categories = []; - let catCommands = {}; - Object.entries(this.commands).sort().forEach(([key, value]) => { - if (value.role !== 'owner' || checkPermission(msg, 'owner')) - if (!categories.includes(value.category)) { - categories.push(value.category); - catCommands[value.category] = `\`${key}\` \t`; - } else { - catCommands[value.category] += `\`${key}\` \t`; - } - - }); - for (let cat of categories) - helpEmbed.addField(cat, catCommands[cat]); - - helpEmbed.setFooter(prefix + 'help [command] for more info to each command'); - return helpEmbed; - } - }); - - // show all roles that are used by commands - this.createCommand(scmdTempl.utils.roles, () => { - let roles = []; - Object.values(globCommands).concat(Object.values(this.commands)).sort().forEach((value) => { - roles.push(value.role || 'all'); - }); - return `**Roles**\n${[...new Set(roles)].join('\n')}`; - }); - } - - /** - * Creates a command entry in the private commands dict - * @param template - * @param call - */ - createCommand(template, call) { - if (!template.name) { - logger.debug(`Name of command template is null or undef. Failed creating command.`); - return; - } - this.commands[this.prefix + template.name] = { - 'args': template.args || [], - 'description': template.description, - 'callback': call, - 'role': template.permission, - 'category': template.category || 'Other' - }; - logger.debug(`Created server command: ${this.prefix + template.name}, args: ${template.args}`); - } - - /** - * Removes a command - * @param command - * @deprecated Why would you want to remove a command? - */ - removeCommand(command) { - delete this.commands[command]; - } - - /** - * Processes the command - * @param msg - * @param globResult - * @param content - * @param returnFunction Boolean if the return value should be a function. - * @param fallback - * @returns {*} - */ - processCommand(msg, globResult, content, returnFunction, fallback) { - let command = (content.match(/^.\w+/) || [])[0]; - if (!command || !this.commands[command]) - if (fallback && !globResult) { - command = fallback; - content = `${fallback} ${content}`; - } else { - return globResult; - } - let cmd = this.commands[command]; - if (!checkPermission(msg, cmd.role)) - return 'No Permission'; - logger.debug(`Permission <${cmd.role || 'all'}> granted for command ${command} for user <${msg.author.tag}>`); - let argvars = content.match(/(?<= )\S+/g) || []; - let kwargs = {}; - let nLength = Math.min(cmd.args.length, argvars.length); - for (let i = 0; i < nLength; i++) - kwargs[cmd.args[i]] = argvars[i]; - - let argv = argvars.slice(nLength); - logger.debug(`Executing callback for command: ${command}, kwargs: ${kwargs}, argv: ${argv}`); - try { - let locResult = returnFunction? () => cmd.callback(msg, kwargs, argv) : cmd.callback(msg, kwargs, argv); - return locResult || globResult; - } catch (err) { - logger.error(err.message); - return `The command \`${command}\` has thrown an error.`; - } - } - - /** - * Parses the message and executes the command callback for the found command entry in the commands dict - * @param msg - * @returns {*} - */ - parseCommand(msg) { - let globResult = parseGlobalCommand(msg); - logger.debug(`Global command result is ${globResult}`); - let content = msg.content; - let commands = content.split(/(? x.replace(/^ +/, '')); - if (commands.length === 1) { - return this.processCommand(msg, globResult, content); - } else if (commands.length < (config.maxCmdSequenceLength || 5)) { - let answers = []; - let previousCommand = (commands[0].match(/^.\w+/) || [])[0]; - for (let i = 0; i < commands.length; i++) { - answers.push(this.processCommand(msg, globResult[i], commands[i], - true, previousCommand)); // return function to avoid "race conditions" - let commandMatch = (commands[i].match(/^.\w+/) || [])[0]; - previousCommand = this.commands[commandMatch]? commandMatch : previousCommand; - } - - return answers; - } else { - return 'This command sequence is too long!'; - } - } - -}; - -/** - * Getting the logger - * @param {Object} newLogger - */ -exports.setLogger = function (newLogger) { - logger = newLogger; -}; - -/** - * Creates a global command that can be executed in every channel. - * @param prefix - * @param template - * @param call - */ -exports.createGlobalCommand = function (prefix, template, call) { - if (!template.name) { - logger.debug(`Name of command template is null or undef. Failed to create command.`); - return; - } - globCommands[prefix + template.name] = { - 'args': template.args || [], - 'description': template.description, - 'callback': call, - 'role': template.permission, - 'name': template.name, - 'category': template.category || 'Other' - }; - logger.debug(`Created global command: ${prefix + template.name}, args: ${template.args}`); -}; - - -/** - * Parses a message for a global command - * @param msg - * @returns {boolean|*} - */ -exports.parseMessage = function (msg) { - return parseGlobalCommand(msg); -}; - -/** - * Initializes the module by creating a help command - */ -exports.init = function (prefix) { - logger.verbose("Created help command"); - this.createGlobalCommand((prefix || config.prefix), gcmdTempl.utils.help, (msg, kwargs) => { - if (kwargs.command) { - let cmd = kwargs.command; - if (cmd.charAt(0) !== prefix) - cmd = prefix + cmd; - if (globCommands[cmd]) - return new Discord.RichEmbed() - .setTitle(`Help for ${cmd}`) - .addField('Usage', `\`${cmd} [${globCommands[cmd].args.join('] [')}]\``.replace('[]', '')) - .addField('Description', globCommands[cmd].description) - .addField('Permission Role', globCommands[cmd].role || 'all'); - - } else { - let helpEmbed = new Discord.RichEmbed() - .setTitle('Global Commands') - .setDescription('Create a sequence of commands with `;` (semicolon).') - .setTimestamp(); - let description = ''; - Object.entries(globCommands).sort().forEach(([key, value]) => { - if (value.role === 'owner' && checkPermission(msg, 'owner')) - description += `\`${key}\` \t`; - else if (value.role !== 'owner') - description += `\`${key}\` \t`; - - }); - helpEmbed.setFooter(prefix + 'help [command] for more info to each command'); - helpEmbed.setDescription(description); - return helpEmbed; - } - }); -}; - -function processCommand(cmd, msg, content, returnFunction) { - let argvars = content.match(/(?<= )\S+/g) || []; - let kwargs = {}; - let nLength = Math.min(cmd.args.length, argvars.length); - for (let i = 0; i < nLength; i++) - kwargs[cmd.args[i]] = argvars[i]; - let argv = argvars.slice(nLength); - logger.debug(`Executing callback for command: ${cmd.name}, kwargs: ${JSON.stringify(kwargs)}, argv: ${argv}`); - return returnFunction? () => cmd.callback(msg, kwargs, argv) : cmd.callback(msg, kwargs, argv); -} - -/** - * Parses the message by calling the assigned function for the command with arguments - * @param msg - */ -function parseGlobalCommand(msg) { - let content = msg.content; - let commands = content.split(/(? x.replace(/^ +/, '')); - if (commands.length === 1) { - let command = (content.match(/^.\w+/) || [])[0]; - if (!command || !globCommands[command]) - return false; - let cmd = globCommands[command]; - if (!checkPermission(msg, cmd.role)) - return false; - logger.debug(`Permission <${cmd.role}> granted for command ${command} for user <${msg.author.tag}>`); - return processCommand(cmd, msg, content); - } else if (commands.length < (config.maxCmdSequenceLength || 5)) { - let answers = []; - let previousCommand = ''; - for (let commandPart of commands) { - let command = (commandPart.match(/^.\w+/) || [])[0] || previousCommand; - previousCommand = globCommands[command]? command : previousCommand; - if (!commandPart || !globCommands[command]) { - commandPart = `${previousCommand} ${commandPart}`; - command = previousCommand; - } - if (command && globCommands[command]) { - let cmd = globCommands[command]; - if (checkPermission(msg, cmd.role)) { - logger.debug(`Permission <${cmd.role}> granted for command ${command} for user <${msg.author.tag}>`); - answers.push(processCommand(cmd, msg, commandPart, - true)); // return an function to avoid "race conditions" - } else { - answers.push(false); - } - } else { - answers.push(false); - } - } - return answers; - } else { - return 'This command sequence is too long!'; - } -} - -/** - * @param msg - * @param rolePerm {String} - * @returns {boolean} - */ -function checkPermission(msg, rolePerm) { - if (!rolePerm || ['all', 'any', 'everyone'].includes(rolePerm)) - return true; - if (msg.author.tag === args.owner || config.owners.includes(msg.author.tag)) - return true; - else - if (msg.member && rolePerm && rolePerm !== 'owner' && msg.member.roles - .some(role => (role.name.toLowerCase() === rolePerm.toLowerCase() || role.name.toLowerCase() === 'botcommander'))) - return true; - - return false; -} diff --git a/lib/commands/AnilistApiCommands/AniListCommandsTemplate.yaml b/lib/commands/AnilistApiCommands/AniListCommandsTemplate.yaml new file mode 100644 index 0000000..f7961af --- /dev/null +++ b/lib/commands/AnilistApiCommands/AniListCommandsTemplate.yaml @@ -0,0 +1,58 @@ +anime_search: + name: alAnime + permission: all + usage: alAnime [search query] + description: > + Searches [AniList.co](https://anilist.co) for the anime *title* or *id* and returns information about + it if there is a result. The staff members are not included because the message would grow too big. + category: AniList + response: + not_found: > + I couldn't find the anime you were searching for :( + +anime_staff_search: + name: alAnimeStaff + permission: all + usage: alAnimeStaff [search query] + description: > + Searches [AniList.co](https://anilist.co) for the anime *title* or *id* and returns all staff members. + category: AniList + response: + not_found: > + I couldn't find the anime you were searching for :( + +manga_search: + name: alManga + permission: all + usage: alManga [search query] + description: > + Searches [AniList.co](https://anilist.co) for the manga *title* or *id* and returns information about + it if there is a result. + category: AniList + response: + not_found: > + I couldn't find the manga you were searching for :( + +staff_search: + name: alStaff + permission: all + usage: alStaff [search query] + description: > + Searches [AniList.co](https://anilist.co) for the staff member *name* or *id* and returns information about + the member aswell as roles in media. + category: AniList + response: + not_found: > + I couldn't find the staff member you were searching for :( + +character_search: + name: alCharacter + permission: all + usage: alCharacter [search query] + description: > + Searches [AniList.co](https://anilist.co) for the character *name* or *id* and returns information about + the character aswell as media roles. + category: AniList + response: + not_found: > + I couldn't find the character member you were searching for :( diff --git a/lib/commands/AnilistApiCommands/index.js b/lib/commands/AnilistApiCommands/index.js new file mode 100644 index 0000000..76414cc --- /dev/null +++ b/lib/commands/AnilistApiCommands/index.js @@ -0,0 +1,306 @@ +const cmdLib = require('../../CommandLib'), + anilistApi = require('../../api/AnilistApi'), + location = './lib/commands/AnilistApiCommands'; + +/** + * The AniList commands are all commands that interact with the anilist api. + */ + +/** + * Returns a string for a name. + * @param nameNode {String} The AniList name node in format {first, last, native} + */ +function getNameString(nameNode) { + let name = ''; + if (nameNode.first) + name = nameNode.first; + if (nameNode.last) + name += ' ' + nameNode.last; + if (name.length === 0) + name = nameNode.native; + return name; +} + +class RichMediaInfo extends cmdLib.ExtendedRichEmbed { + + /** + * Creates a rich embed with info for AniListApi Media. + * @param mediaInfo + */ + constructor(mediaInfo) { + super(mediaInfo.title.romaji); + this.setThumbnail(mediaInfo.coverImage.large || mediaInfo.coverImage.medium) + .setURL(mediaInfo.siteUrl) + .setColor(mediaInfo.coverImage.color) + .setFooter('Powered by AniList.co'); + if (mediaInfo.description) + this.setDescription(mediaInfo.description + .replace(/<\/?.*?>/g, '') + .replace(/~!.*?!~/g, '') + .replace(/\n\n\n/g, '')); + let fields = { + 'Genres': mediaInfo.genres? mediaInfo.genres.join(' ') : null, + 'Studios': mediaInfo.studios? mediaInfo.studios.studioList.map(x => `[${x.name}](${x.siteUrl})`) : null, + 'Scoring': mediaInfo.averageScore? `**AverageScore**: ${mediaInfo.averageScore}\n**Favourites:** ${mediaInfo.favourites}`: null, + 'Episodes': mediaInfo.episodes, + 'Volumes': mediaInfo.volumes, + 'Chapters': mediaInfo.chapters, + 'Duration': null, + 'Season': mediaInfo.season, + 'Status': mediaInfo.status, + 'Format': mediaInfo.format + }; + if (mediaInfo.duration) + fields['Episode Duration'] = `${mediaInfo.duration} min`; + if (mediaInfo.startDate && mediaInfo.startDate.day) + fields['Start Date'] = `${mediaInfo.startDate.day}.${mediaInfo.startDate.month}.${mediaInfo.startDate.year}`; + if (mediaInfo.nextAiringEpisode) { + let epInfo = mediaInfo.nextAiringEpisode; + fields['Next Episode'] = `**Episode** ${epInfo.episode}\n**Airing at:** ${new Date(epInfo.airingAt * 1000).toUTCString()}`; + } + if (mediaInfo.endDate && mediaInfo.endDate.day) + fields['End Date'] = `${mediaInfo.endDate.day}.${mediaInfo.endDate.month}.${mediaInfo.endDate.year}`; + this.addStaffInfo(mediaInfo); + this.addFields(fields); + } + + addStaffInfo(mediaInfo) { + let fields = {}; + if (mediaInfo.staff && mediaInfo.staff.edges) { + let staffContent = mediaInfo.staff.edges.map((x) => { + let url = x.node.siteUrl; + let name = getNameString(x.node.name); + return `**${x.role}:** [${name}](${url})`; + }); + let staffFieldValue = staffContent.join('\n'); + if (staffFieldValue.length > 1024) { + let staffValues = []; + let currentValue = ''; + + for (let staffLine of staffContent) { + let concatValue = currentValue + '\n' + staffLine; + if (concatValue.length > 1024) { + staffValues.push(currentValue); + currentValue = staffLine; + } else { + currentValue = concatValue; + } + } + staffValues.push(currentValue); + for (let i = 0; i < staffValues.length; i++) + fields[`Staff part ${i + 1}`] = staffValues[i]; + } else { + fields['Staff'] = staffFieldValue; + } + } + this.addFields(fields); + } +} + +class RichStaffInfo extends cmdLib.ExtendedRichEmbed { + + /** + * A Rich Embed with informatin about an AniList staff member. + * @param staffInfo + */ + constructor(staffInfo) { + super(getNameString(staffInfo.name)); + this.setThumbnail(staffInfo.image.large || staffInfo.image.medium) + .setURL(staffInfo.siteUrl); + let fields = { + 'Language': staffInfo.language + }; + if (staffInfo.staffMedia && staffInfo.staffMedia.edges) + fields['Staff Media Roles (first 10)'] = staffInfo.staffMedia.edges.map(x => { + let node = x.node; + let title = node.title.romaji; + let url = node.siteUrl; + return `[**${title}**](${url}): ${x.staffRole}`; + }).join('\n'); + if (staffInfo.characters && staffInfo.characters.nodes) + fields['Staff Character Roles (first 10)'] = staffInfo.characters.nodes.map(x => { + let name = getNameString(x.name); + let url = x.siteUrl; + return `[${name}](${url})`; + }).join('\n'); + + + this.addFields(fields); + } +} + +class RichCharacterInfo extends cmdLib.ExtendedRichEmbed { + + /** + * A RichEmbed with information about an AniList character. + * @param characterInfo {Object} + */ + constructor(characterInfo) { + super(getNameString(characterInfo.name)); + this.setURL(characterInfo.siteUrl) + .setThumbnail(characterInfo.image.large || characterInfo.image.medium); + if (characterInfo.description) + this.setDescription(characterInfo.description + .replace(/<\/?.*?>/g, '') + .replace(/~!.*?!~/g, '') + .replace(/\n\n\n/g, '')); + if (characterInfo.media && characterInfo.media.edges) + this.addNonemptyField( + 'Media Appeareance', + characterInfo.media.edges.map(x => { + let media = x.node; + let informationString = `**[${media.title.romaji}](${media.siteUrl})**: ${x.characterRole}`; + if (x.voiceActors && x.voiceActors.length > 0) + informationString += ` voice by ${x.voiceActors.map(y => { + return `[${getNameString(y.name)}](${y.siteUrl})`; + }).join(', ')}`; + return informationString; + }).join('\n') + ); + } +} + +// -- initialize -- // + +/** + * Implementing the AniList commands module. + */ +class AniListCommandModule extends cmdLib.CommandModule { + + constructor() { + super(cmdLib.CommandScopes.Global); + this.templateFile = location + '/AniListCommandsTemplate.yaml'; + this.template = null; + } + + async register(commandHandler) { + await this._loadTemplate(); + + let animeSearch = new cmdLib.Command( + this.template.anime_search, + new cmdLib.Answer(async (m, k, s) => { + try { + let animeData = {}; + if (/^\d+$/.test(s)) + animeData = await anilistApi.getAnimeById(s, false, true); + else + animeData = await anilistApi.searchAnimeByName(s, false, true); + this._logger.silly(`Anime Query returned ${JSON.stringify(animeData)}`); + return new RichMediaInfo(animeData); + } catch (err) { + if (err.message) { + this._logger.verbose(err.message); + this._logger.silly(err.stack); + } else if (err.errors) { + this._logger.silly(`Graphql Errors ${JSON.stringify(err.errors)}`); + } + return this.template.anime_search.response.not_found; + } + }) + ); + + let animeStaffSearch = new cmdLib.Command( + this.template.anime_staff_search, + new cmdLib.Answer(async (m, k, s) => { + try { + let animeData = {}; + if (/^\d+$/.test(s)) + animeData = await anilistApi.getAnimeById(s, true, false); + else + animeData = await anilistApi.searchAnimeByName(s, true, false); + this._logger.silly(`Anime Query returned ${JSON.stringify(animeData)}`); + return new RichMediaInfo(animeData); + } catch (err) { + if (err.message) { + this._logger.verbose(err.message); + this._logger.silly(err.stack); + } else if (err.errors) { + this._logger.silly(`Graphql Errors ${JSON.stringify(err.errors)}`); + } + return this.template.anime_staff_search.response.not_found; + } + }) + ); + + let mangaSearch = new cmdLib.Command( + this.template.manga_search, + new cmdLib.Answer(async (m, k, s) => { + try { + let mangaData = {}; + if (/^\d+$/.test(s)) + mangaData = await anilistApi.getMangaById(s, true, true); + else + mangaData= await anilistApi.searchMangaByName(s, true, true); + this._logger.silly(`Manga Query returned ${JSON.stringify(mangaData)}`); + return new RichMediaInfo(mangaData); + } catch (err) { + if (err.message) { + this._logger.verbose(err.message); + this._logger.silly(err.stack); + } else if (err.errors) { + this._logger.silly(`Graphql Errors ${JSON.stringify(err.errors)}`); + } + return this.template.manga_search.response.not_found; + } + }) + ); + + let staffSearch = new cmdLib.Command( + this.template.staff_search, + new cmdLib.Answer(async (m, k, s) => { + try { + let staffData = {}; + if (/^\d+$/.test(s)) + staffData = await anilistApi.getStaffById(s); + else + staffData = await anilistApi.searchStaffByName(s); + this._logger.silly(`Staff Query returned ${JSON.stringify(staffData)}`); + return new RichStaffInfo(staffData); + } catch (err) { + if (err.message) { + this._logger.verbose(err.message); + this._logger.silly(err.stack); + } else if (err.errors) { + this._logger.silly(`Graphql Errors ${JSON.stringify(err.errors)}`); + } + return this.template.staff_search.response.not_found; + } + }) + ); + + let characterSearch = new cmdLib.Command( + this.template.character_search, + new cmdLib.Answer(async (m, k, s) => { + try { + let characterData = {}; + if (/^\d+$/.test(s)) + characterData = await anilistApi.getCharacterById(s); + else + characterData = await anilistApi.searchCharacterByName(s); + this._logger.silly(`Character Query returned ${JSON.stringify(characterData)}`); + return new RichCharacterInfo(characterData); + } catch (err) { + if (err.message) { + this._logger.verbose(err.message); + this._logger.silly(err.stack); + } else if (err.errors) { + this._logger.silly(`Graphql Errors ${JSON.stringify(err.errors)}`); + } + return this.template.character_search.response.not_found; + } + }) + ); + + // registering commands + commandHandler + .registerCommand(animeSearch) + .registerCommand(mangaSearch) + .registerCommand(staffSearch) + .registerCommand(animeStaffSearch) + .registerCommand(characterSearch); + } +} + +Object.assign(exports, { + 'module': AniListCommandModule +}); diff --git a/lib/commands/InfoCommands/InfoCommandsTemplate.yaml b/lib/commands/InfoCommands/InfoCommandsTemplate.yaml new file mode 100644 index 0000000..e58e4c5 --- /dev/null +++ b/lib/commands/InfoCommands/InfoCommandsTemplate.yaml @@ -0,0 +1,44 @@ +about: + name: about + description: > + Shows information about this Discord Bot. + permission: all + category: Info + response: + about_icon: | + This icon war created by [blackrose14344](https://www.deviantart.com/blackrose14344). + [Original](https://www.deviantart.com/blackrose14344/art/2B-Chi-B-685771489) + about_creator: | + This bot was created by Trivernis. + More about this bot [here](https://github.com/Trivernis/discordbot.js). + +ping: + name: ping + description: > + Answers with the current average ping of the bot. + permission: all + category: Info + +uptime: + name: uptime + description: > + Answers with the uptime of the bot. + permission: all + category: Info + +guilds: + name: guilds + description: > + Answers with the number of guilds the bot has joined + permission: owner + category: Info + +help: + name: help + description: > + Shows help for bot ocmmands. + permission: all + category: Info + embed_color: 0xffffff + args: + - command diff --git a/lib/commands/InfoCommands/index.js b/lib/commands/InfoCommands/index.js new file mode 100644 index 0000000..e2cc39b --- /dev/null +++ b/lib/commands/InfoCommands/index.js @@ -0,0 +1,114 @@ +const cmdLib = require('../../CommandLib'), + utils = require('../../utils'), + location = './lib/commands/InfoCommands'; + +/** + * Info commands provide information about the bot. These informations are + * not process specific but access the discord _client instance of the bot. + */ + +class InfoCommandModule extends cmdLib.CommandModule { + + /** + * @param opts {Object} properties: + * client - the instance of the discord client. + * messageHandler - the instance of the Message Handler + */ + constructor(opts) { + super(cmdLib.CommandScopes.Global); + this.templateFile = location + '/InfoCommandsTemplate.yaml'; + this._client = opts.client; + this._messageHandler = opts.messageHandler; + } + + _createHelpEmbed(commands, msg, prefix, embedColor = 0xfff) { + let helpEmbed = new cmdLib.ExtendedRichEmbed('Commands') + .setDescription('Create a sequence of commands with `;` and `&&`.') + .setColor(embedColor); + let categories = []; + let catCommands = {}; + Object.entries(commands).sort().forEach(([key, value]) => { + if (!categories.includes(value.category)) { + categories.push(value.category); + catCommands[value.category] = `\`${prefix}${key}\` \t`; + } else { + catCommands[value.category] += `\`${prefix}${key}\` \t`; + } + }); + for (let cat of categories) + helpEmbed.addField(cat, catCommands[cat]); + + helpEmbed.setFooter(prefix + 'help [command] for more info to each command'); + this._logger.silly('Created help embed'); + return helpEmbed; + } + + async register(commandHandler) { + await this._loadTemplate(); + + let about = new cmdLib.Command( + this.template.about, + new cmdLib.Answer(() => { + return new cmdLib.ExtendedRichEmbed('About') + .setDescription(this.template.about.response.about_creator) + .addField('Icon', this.template.about.response.about_icon); + }) + ); + + let ping = new cmdLib.Command( + this.template.ping, + new cmdLib.Answer(() => { + return `Current average ping: \`${this._client.ping} ms\``; + }) + ); + + let uptime = new cmdLib.Command( + this.template.uptime, + new cmdLib.Answer(() => { + let uptime = utils.getSplitDuration(this._client.uptime); + return new cmdLib.ExtendedRichEmbed('Uptime').setDescription(` + **${uptime.days}** days + **${uptime.hours}** hours + **${uptime.minutes}** minutes + **${uptime.seconds}** seconds + **${uptime.milliseconds}** milliseconds + `).setTitle('Uptime'); + }) + ); + + let guilds = new cmdLib.Command( + this.template.guilds, + new cmdLib.Answer(() => { + return `Number of guilds: \`${this._client.guilds.size}\``; + }) + ); + + let help = new cmdLib.Command( + this.template.help, + new cmdLib.Answer((m, k) => { + let globH = this._messageHandler.globalCmdHandler; + let scopeH = this._messageHandler.getScopeHandler(m); + if (k.command) { + k.command = k.command.replace(globH.prefix, ''); + let commandInstance = globH.commands[k.command] || scopeH.commands[k.command]; + return commandInstance.help.setColor(this.template.help.embed_color); + } else { + let commandObj = {...globH.commands, ...scopeH.commands}; + return this._createHelpEmbed(commandObj, m, globH.prefix, this.template.help.embed_color); + } + }) + ); + + // register commands + commandHandler + .registerCommand(about) + .registerCommand(ping) + .registerCommand(uptime) + .registerCommand(guilds) + .registerCommand(help); + } +} + +Object.assign(exports, { + 'module': InfoCommandModule +}); diff --git a/lib/commands/MusicCommands/MusicCommandsTemplate.yaml b/lib/commands/MusicCommands/MusicCommandsTemplate.yaml new file mode 100644 index 0000000..9b574ea --- /dev/null +++ b/lib/commands/MusicCommands/MusicCommandsTemplate.yaml @@ -0,0 +1,178 @@ +play: + name: play + description: > + Adds the url to the YouTube video or YouTube playlist into the queue. + permission: all + category: Music + args: + - url + response: + success: > + Added URL to the media queue. + failure: > + Failed adding the URL to the media queue. + url_invalid: > + The URL you provided is not a valid YouTube video or Playlist URL. + no_url: > + You need to provide an URL to a YouTube viceo or Playlist. + no_voicechannel: > + You need to join a VoiceChannel to request media playback. + +play_next: + name: playnext + description: > + Adds the url to the YouTube video or YouTube playlist into the queue as + next playing song. + permission: all + category: Music + args: + - url + response: + success: > + Added URL as next media to the media queue. + failure: > + Failed adding the URL to the media queue. + url_invalid: > + The URL you provided is not a valid YouTube video or Playlist URL. + no_url: > + You need to provide an URL to a YouTube viceo or Playlist. + no_voicechannel: > + You need to join a VoiceChannel to request media playback. + +join: + name: join + description: > + Joins the VoiceChannel you are in. + permission: all + category: Music + response: + no_voicechannel: > + You need to join a VoiceChannel for me to join. + +stop: + name: stop + description: > + Stops the media playback and leaves the VoiceChannel. + permission: all + category: Music + response: + success: > + Stopped music playback. + not_playing: > + I'm not playing music at the moment. What do you want me to stop? + +pause: + name: pause + description: > + Pauses the media playback. + permission: all + category: Music + response: + success: > + Paused playback. + not_playing: > + I'm not playing music at the moment. + +resume: + name: resume + description: > + Resumes the media playback. + permission: all + category: Music + response: + success: > + Resumed playback. + not_playing: > + I'm not playing music at the moment. + +skip: + name: skip + description: > + Skips the currently playing song. + permission: all + category: Music + response: + success: > + Skipped to the next song. + not_playing: > + I'm not playing music at the moment. + +clear: + name: clear + description: > + Clears the media queue. + permission: musicPlayer + category: Music + response: + success: > + The media queue has been cleared. + +media_queue: + name: queue + descriptions: > + Shows the next ten songs in the media queue. + permission: all + category: Music + +media_current: + name: np + description: > + Shows the currently playing song. + permission: all + category: Music + response: + not_playing: > + I'm not playing music at the moment. + +shuffle: + name: shuffle + description: > + Shuffles the media queue + permission: all + category: Music + response: + success: > + The queue has been shuffled. + +toggle_repeat: + name: repeat + description: > + Toggles listening o repeat. + permission: all + category: Music + response: + repeat_true: > + Listening on repeat now! + repeat_false: > + Not listening on repeat anymore. + +save_media: + name: savemedia + description: > + Saves the YouTube URL with a specific name. + permission: dj + category: Music + args: + - url + usage: savemedia [url] [name...] + +delete_media: + name: deletemedia + description: > + Deletes a saved YouTube URL from saved media. + permission: dj + category: Music + usage: deletemedia [name] + response: + no_name: > + You must provide a name for the media to delete. + +saved_media: + name: savedmedia + description: > + Shows all saved YouTube URLs. + permission: all + category: Music + response: + no_saved: > + There are no saved YouTube URLs :( diff --git a/lib/commands/MusicCommands/index.js b/lib/commands/MusicCommands/index.js new file mode 100644 index 0000000..5af3cee --- /dev/null +++ b/lib/commands/MusicCommands/index.js @@ -0,0 +1,318 @@ +const cmdLib = require('../../CommandLib'), + utils = require('../../utils'), + config = require('../../../config'), + location = './lib/commands/MusicCommands'; + +function checkPermission(msg, rolePerm) { + if (!rolePerm || ['all', 'any', 'everyone'].includes(rolePerm)) + return true; + if (config.owners.includes(msg.author.tag)) + return true; + else if (msg.member && rolePerm && rolePerm !== 'owner' && msg.member.roles + .some(role => (role.name.toLowerCase() === rolePerm.toLowerCase() || + role.name.toLowerCase() === 'botcommander'))) + return true; + return false; +} + +/** + * Music commands provide commands to control the bots music functions. + * These commands are for server music functionalities. + */ +class MusicCommandModule extends cmdLib.CommandModule { + + /** + * @param opts {Object} properties: + * getGuildHandler - a function to get the guild handler for a guild. + */ + constructor(opts) { + super(cmdLib.CommandScopes.Guild); + this.templateFile = location + '/MusicCommandsTemplate.yaml'; + this._getGuildHandler = opts.getGuildHandler; + } + + /** + * Connects to a voice-channel if not connected and plays the url + * @param gh {guilding.GuildHandler} + * @param vc {Discord.VoiceChannel} + * @param url {String} The url to the YouTube media + * @param next {Boolean} Should the song be played next + * @returns {Promise} + * @private + */ + async _connectAndPlay(gh, vc, url, next) { + if (!gh.musicPlayer.connected) { + await gh.musicPlayer.connect(vc); + await gh.musicPlayer.playYouTube(url, next); + } else { + await gh.musicPlayer.playYouTube(url, next); + } + } + + /** + * The play function for the music commands play and playnext + * @param m {Discord.Message} + * @param k {Object} kwargs + * @param s {String} argsString + * @param t {Object} template + * @param n {Boolean} play next + * @returns {Promise<*>} + * @private + */ + async _playFunction(m, k, s, t, n) { + let gh = await this._getGuildHandler(m.guild); + let vc = gh.musicPlayer.voiceChannel || m.member.voiceChannel; + let url = k['url']; + if (!vc) + return t.response.no_voicechannel; + if (!url) + return t.response.no_url; + if (!utils.YouTube.isValidEntityUrl(url)) { + url = s; + let row = await gh.db.get('SELECT url FROM playlists WHERE name = ?', [url]); + if (!row) { + this._logger.debug('Got invalid url for play command.'); + return t.response.url_invalid; + } else { + await this._connectAndPlay(gh, vc, row.url, n); + return t.response.success; + } + } else { + await this._connectAndPlay(gh, vc, url, n); + return t.response.success; + } + } + + async register(commandHandler) { + await this._loadTemplate(); + + let play = new cmdLib.Command( + this.template.play, + new cmdLib.Answer(async (m, k, s) => { + return await this._playFunction(m, k, s, this.template.play, false); + }) + ); + + let playNext = new cmdLib.Command( + this.template.play_next, + new cmdLib.Answer(async (m, k, s) => { + return await this._playFunction(m, k, s, this.template.play_next, true); + }) + ); + + let join = new cmdLib.Command( + this.template.join, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + if (m.member.voiceChannel) + await gh.musicPlayer.connect(m.member.voiceChannel); + else + return this.template.join.response.no_voicechannel; + }) + ); + + let stop = new cmdLib.Command( + this.template.stop, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + let vc = gh.musicPlayer.voiceChannel || m.member.voiceChannel; + if (gh.musicPlayer.connected && vc) { + let votes = gh.updateCommandVote(stop.name, m.author.tag); + let neededVotes = Math.ceil(vc.members.size/2); + + if (neededVotes <= votes.count || checkPermission(m, 'dj')) { + this._logger.debug(`Vote passed. ${votes.count} out of ${neededVotes} for stop or permission granted`); + gh.musicPlayer.stop(); + gh.resetCommandVote(stop.name); + return this.template.stop.success; + } else { + this._logger.silly(`Vote count increased. ${votes.count} out of ${neededVotes} for stop`); + return `${votes.count} out of ${neededVotes} needed voted to stop.`; + } + } else { + return this.template.stop.not_playing; + } + }) + ); + + let pause = new cmdLib.Command( + this.template.pause, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + if (gh.musicPlayer.playing) { + gh.musicPlayer.pause(); + return this.template.pause.response.success; + } else { + return this.template.pause.response.not_playing; + } + }) + ); + + let resume = new cmdLib.Command( + this.template.resume, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + if (gh.musicPlayer.playing) { + gh.musicPlayer.resume(); + return this.template.resume.response.success; + } else { + return this.template.resume.response.not_playing; + } + }) + ); + + let skip = new cmdLib.Command( + this.template.skip, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + let vc = gh.musicPlayer.voiceChannel || m.member.voiceChannel; + if (gh.musicPlayer.playing && vc) { + let votes = gh.updateCommandVote(skip.name, m.author.tag); + let neededVotes = Math.ceil(vc.members.size/2); + + if (neededVotes <= votes.count || checkPermission(m, 'dj')) { + this._logger.debug(`Vote passed. ${votes.count} out of ${neededVotes} for skip or permission granted`); + gh.musicPlayer.skip(); + gh.resetCommandVote(skip.name); + return this.template.skip.response.success; + } else { + this._logger.silly(`Vote count increased. ${votes.count} out of ${neededVotes} for skip`); + return `${votes.count} out of ${neededVotes} needed voted to skip.`; + } + } else { + return this.template.skip.response.not_playing; + } + }) + ); + + let clear = new cmdLib.Command( + this.template.clear, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + gh.musicPlayer.clear(); + return this.template.clear.response.success; + }) + ); + + let mediaQueue = new cmdLib.Command( + this.template.media_queue, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + this._logger.debug(`Found ${gh.musicPlayer.queue.length} songs.`); + let description = ''; + + for (let i = 0; i < Math.min(gh.musicPlayer.queue.length, 9); i++) { + let entry = gh.musicPlayer.queue[i]; + description += `[${entry.title}](${entry.url})\n`; + } + return new cmdLib.ExtendedRichEmbed(`${gh.musicPlayer.queue.length} songs in queue`) + .setDescription(description); + }) + ); + + let mediaCurrent = new cmdLib.Command( + this.template.media_current, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + let song = gh.musicPlayer.song; + if (song) + return new cmdLib.ExtendedRichEmbed('Now playing:') + .setDescription(`[${song.title}](${song.url})`) + .setImage(utils.YouTube.getVideoThumbnailUrlFromUrl(song.url)) + .setColor(0x00aaff); + else + return this.template.media_current.response.not_playing; + }) + ); + + let shuffle = new cmdLib.Command( + this.template.shuffle, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + gh.musicPlayer.shuffle(); + return this.template.shuffle.response.success; + }) + ); + + let toggleRepeat = new cmdLib.Command( + this.template.toggle_repeat, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + gh.musicPlayer.repeat = !gh.musicPlayer.repeat; + return gh.musicPlayer.repeat? + this.template.toggle_repeat.response.repeat_true : + this.template.toggle_repeat.response.repeat_false; + }) + ); + + let saveMedia = new cmdLib.Command( + this.template.save_media, + new cmdLib.Answer(async (m, k, s) => { + let gh = await this._getGuildHandler(m.guild); + let saveName = s.replace(k.url + ' ', ''); + let row = await gh.db + .get('SELECT COUNT(*) count FROM playlists WHERE name = ?', [saveName]); + if (!row || row.count === 0) + await gh.db.run('INSERT INTO playlists (name, url) VALUES (?, ?)', + [saveName, k.url]); + else + await gh.db.run('UPDATE playlists SET url = ? WHERE name = ?', + [k.url, saveName]); + return `Saved song/playlist as ${saveName}`; + }) + ); + + let deleteMedia = new cmdLib.Command( + this.template.delete_media, + new cmdLib.Answer(async (m, k, s) => { + let gh = await this._getGuildHandler(m.guild); + if (!s) { + return this.template.delete_media.response.no_name; + } else { + await gh.db.run('DELETE FROM playlists WHERE name = ?', [s]); + return `Deleted ${s} from saved media`; + } + }) + ); + + let savedMedia = new cmdLib.Command( + this.template.saved_media, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + let response = ''; + let rows = await gh.db.all('SELECT name, url FROM playlists'); + for (let row of rows) + response += `[${row.name}](${row.url})\n`; + + if (rows.length === 0) + return this.template.saved_media.response.no_saved; + else + return new cmdLib.ExtendedRichEmbed('Saved Songs and Playlists') + .setDescription(response) + .setFooter(`Play a saved entry with play [Entryname]`); + }) + ); + + // register commands + commandHandler + .registerCommand(play) + .registerCommand(playNext) + .registerCommand(join) + .registerCommand(stop) + .registerCommand(pause) + .registerCommand(resume) + .registerCommand(skip) + .registerCommand(clear) + .registerCommand(mediaQueue) + .registerCommand(mediaCurrent) + .registerCommand(shuffle) + .registerCommand(toggleRepeat) + .registerCommand(saveMedia) + .registerCommand(deleteMedia) + .registerCommand(savedMedia); + } +} + +Object.assign(exports, { + 'module': MusicCommandModule +}); diff --git a/lib/commands/ServerUtilityCommands/ServerUtilityCommandsTemplate.yaml b/lib/commands/ServerUtilityCommands/ServerUtilityCommandsTemplate.yaml new file mode 100644 index 0000000..92db20e --- /dev/null +++ b/lib/commands/ServerUtilityCommands/ServerUtilityCommandsTemplate.yaml @@ -0,0 +1,48 @@ +save_cmd: + name: savecmd + description: > + Saves a sequence of commands under a new name. + permission: moderator + category: Server Utility + usage: savecmd [cmdname] [cmdsequence] + args: + - name + response: + no_recursion: > + You are **not** allowed to execute another saved command in this sequence. + This is to prevent recursion that could break the bot. + sequence_too_many_parallel: > + This sequence executes too many commands in parallel. + sequence_too_many_serial: > + This sequence executes too long serial chains. + +delete_cmd: + name: deletecmd + description: > + Deletes a saved command. + permission: moderator + category: Server Utility + args: + - name + +saved_cmd: + name: savedcmd + description: > + Lists all saved commands. + category: Server Utility + permission: all + response: + no_commands: > + There are no saved commands. + +execute: + name: execute + description: > + Executes a saved command. + permission: all + category: Server Utility + args: + - name + response: + not_found: > + The command to be executed couldn't be found. diff --git a/lib/commands/ServerUtilityCommands/index.js b/lib/commands/ServerUtilityCommands/index.js new file mode 100644 index 0000000..dc833fb --- /dev/null +++ b/lib/commands/ServerUtilityCommands/index.js @@ -0,0 +1,133 @@ +const cmdLib = require('../../CommandLib'), + location = './lib/commands/ServerUtilityCommands'; + +/** + * This command module includes utility commands for the server. + */ +class ServerUtilityCommandModule extends cmdLib.CommandModule { + + /** + * @param opts {Object} properties: + * getGuildHandler - a function to get the guild handler for the guild + * messagehandler - the MessageHandler instance + * config - the config object + */ + constructor(opts) { + super(cmdLib.CommandScopes.Guild); + this.templateFile = location + '/ServerUtilityCommandsTemplate.yaml'; + this._messageHandler = opts.messageHandler; + this._getGuildHandler = opts.getGuildHandler; + this._config = opts.config; + } + + /** + * Serializes a command sequence to string. + * @param sqArray + * @returns {*} + * @private + */ + _serializeCmdSequence(sqArray) { + this._logger.debug(sqArray); + return sqArray.map((x) => x.join(' && ')).join('; '); + } + + /** + * Registers the utility commands. + * @param commandHandler + */ + async register(commandHandler) { + await this._loadTemplate(); + + let saveCmd = new cmdLib.Command( + this.template.save_cmd, + new cmdLib.Answer(async (m, k, s) => { + let gh = await this._getGuildHandler(m.guild); + let sequenceString = s + .replace(new RegExp(`^${k.name}\\s`), '') + .replace(/\\&/g, '&') + .replace(/\\;/g, ';'); + let innerStrings = sequenceString.match(/'.+?'/g) || []; + + for (let innerString of innerStrings) + sequenceString.replace(innerString, innerString + .replace(/&/g, '\\&')) + .replace(/;/g, '\\;'); + sequenceString = sequenceString + .replace(/"/g, '') + .replace(/'/g, '"'); + let sequence = this._messageHandler.parseSyntaxString(sequenceString); + let execCommand = this._config.prefix + this.template.execute.name; + let maxSqPar = this._config.commandSettings.maxSequenceParallel; + let maxSqSer = this._config.commandSettings.maxSequenceSerial; + + if (sequenceString.includes(execCommand)) { + return this.template.save_cmd.response.no_recursion; + } else if (sequence.length > maxSqPar) { + return this.template.save_cmd.response.sequence_too_many_parallel; + } else if (sequence.find(x => x.length > maxSqSer)) { + return this.template.save_cmd.response.sequence_too_many_serial; + } else { + let row = await gh.db + .get('SELECT COUNT(*) count FROM commands WHERE name = ?', [k.name]); + if (!row || row.count === 0) + await gh.db + .run('INSERT INTO commands (name, command) VALUES (?, ?)', [k.name, JSON.stringify(sequence)]); + else + await await gh.db + .run('UPDATE commands SET command = ? WHERE name = ?', [JSON.stringify(sequence), k.name]); + } + }) + ); + + let deleteCmd = new cmdLib.Command( + this.template.delete_cmd, + new cmdLib.Answer(async (m, k) => { + let gh = await this._getGuildHandler(m.guild); + await gh.db.run('DELETE FROM commands WHERE name = ?', [k.name]); + return `Deleted command ${k.name}`; + }) + ); + + let savedCmd = new cmdLib.Command( + this.template.saved_cmd, + new cmdLib.Answer(async (m) => { + let gh = await this._getGuildHandler(m.guild); + let response = new cmdLib.ExtendedRichEmbed('Saved Commands') + .setFooter(`Execute a saved entry with ${this._config.prefix}execute [Entryname]`); + let rows = await gh.db.all('SELECT name, command FROM commands'); + if (rows.length === 0) + return this.template.saved_cmd.response.no_commands; + else + for (let row of rows) + response.addField(row.name, '`' + this._serializeCmdSequence(JSON.parse(row.command)) + '`'); + return response; + }) + ); + + let execute = new cmdLib.Command( + this.template.execute, + new cmdLib.Answer(async (m, k) => { + let gh = await this._getGuildHandler(m.guild); + let row = await gh.db + .get('SELECT command FROM commands WHERE name = ?', [k.name]); + if (row) + await this._messageHandler + .executeCommandSequence(JSON.parse(row.command), m); + else + return this.template.execute.response.not_found; + + }) + ); + + // register commands + commandHandler + .registerCommand(saveCmd) + .registerCommand(deleteCmd) + .registerCommand(savedCmd) + .registerCommand(execute); + } +} + +Object.assign(exports, { + 'module': ServerUtilityCommandModule +}); diff --git a/lib/commands/UtilityCommands/UtilityCommandsTemplate.yaml b/lib/commands/UtilityCommands/UtilityCommandsTemplate.yaml new file mode 100644 index 0000000..488a730 --- /dev/null +++ b/lib/commands/UtilityCommands/UtilityCommandsTemplate.yaml @@ -0,0 +1,44 @@ +shutdown: + name: shutdown + description: > + Shuts down the bot. + permission: owner + category: Utility + +add_presence: + name: addpresence + description: > + Adds a Rich Presence to the bot. + permission: owner + category: Utility + usage: addpresence [presence] + +rotate_presence: + name: rotatepresence + description: > + Forces a presence rotation + permission: owner + category: Utility + +create_user: + name: createuser + description: > + Creates a user for the webinterface. + permission: owner + category: Utility + args: + - username + - password + - scope + +bugreport: + name: bug + description: > + Get information about where to report bugs. + permission: all + category: Utility + response: + title: > + You want to report a bug? + bug_report: > + Please report your bugs [here](https://github.com/Trivernis/discordbot.js/issues) diff --git a/lib/commands/UtilityCommands/index.js b/lib/commands/UtilityCommands/index.js new file mode 100644 index 0000000..49167e4 --- /dev/null +++ b/lib/commands/UtilityCommands/index.js @@ -0,0 +1,111 @@ +const cmdLib = require('../../CommandLib'), + location = './lib/commands/UtilityCommands'; + +/** + * Utility commands are all commands that allow the user to control the behaviour of the + * bot. Utility commands for example are allowed to: + * - manipulate the main database + * - manipulate the bot's presences + * - manipulate the process (e.g. shutdown) + */ + +class UtilityCommandModule extends cmdLib.CommandModule { + + /** + * @param opts {Object} properties: + * bot - the instance of the bot. + * config - the config object + */ + constructor(opts) { + super(cmdLib.CommandScopes.User); + this.templateFile = location + '/UtilityCommandsTemplate.yaml'; + this._bot = opts.bot; + this._config = opts.config; + } + + async register(commandHandler) { + await this._loadTemplate(); + + let addPresence = new cmdLib.Command( + this.template.add_presence, + new cmdLib.Answer(async (m, k, s) => { + this._bot.presences.push(s); + await this._bot.maindb.run('INSERT INTO presences (text) VALUES (?)', [s]); + return `Added Presence \`${s}\``; + }) + ); + + let rotatePresence = new cmdLib.Command( + this.template.rotate_presence, + new cmdLib.Answer(() => { + try { + this._bot.client.clearInterval(this._bot.rotator); + this._bot.rotatePresence(); + this._bot.rotator = this._bot.client.setInterval(() => this._bot.rotatePresence(), + this._config.presence_duration); + } catch (error) { + this._logger.warn(error.message); + } + }) + ); + + let shutdown = new cmdLib.Command( + this.template.shutdown, + new cmdLib.Answer(async (m) => { + try { + await m.reply('Shutting down...'); + this._logger.debug('Destroying client...'); + await this._bot.client.destroy(); + } catch (err) { + this._logger.error(err.message); + this._logger.debug(err.stack); + } + try { + this._logger.debug('Exiting server...'); + await this._bot.webServer.stop(); + } catch (err) { + this._logger.error(err.message); + this._logger.debug(err.stack); + } + try { + this._logger.debug(`Exiting Process...`); + process.exit(0); + } catch (err) { + this._logger.error(err.message); + this._logger.debug(err.stack); + } + }) + ); + + let createUser = new cmdLib.Command( + this.template.create_user, + new cmdLib.Answer(async (m, k) => { + if (k.username &&k.password && k.scope) { + this._logger.debug(`Creating user entry for ${k.username}`); + let token = await this._bot.webServer.createUser( + k.username, k.password, k.scope, false); + return `${k.username}'s token is ${token}`; + } + }) + ); + + let bugReport = new cmdLib.Command( + this.template.bugreport, + new cmdLib.Answer(() => { + return new cmdLib.ExtendedRichEmbed(this.template.bugreport.response.title) + .setDescription(this.template.bugreport.response.bug_report); + }) + ); + + // register commands + commandHandler.registerCommand(addPresence) + .registerCommand(rotatePresence) + .registerCommand(shutdown) + .registerCommand(createUser) + .registerCommand(bugReport); + } +} + +Object.assign(exports, { + 'module': UtilityCommandModule +}); diff --git a/lib/guilding.js b/lib/guilding.js index 0af2aa2..63d03b3 100644 --- a/lib/guilding.js +++ b/lib/guilding.js @@ -1,49 +1,46 @@ -const cmd = require('./cmd'), - music = require('./music'), +const music = require('./MusicLib'), utils = require('./utils'), config = require('../config.json'), sqliteAsync = require('./sqliteAsync'), + logging = require('./logging'), fs = require('fs-extra'), - servercmd = require('../commands/servercommands'), - Discord = require('discord.js'), - waterfall = require('promise-waterfall'), dataDir = config.dataPath || './data'; -let logger = require('winston'); - -exports.setLogger = function (newLogger) { - logger = newLogger; - music.setLogger(logger); - cmd.setLogger(logger); -}; /** - * Server-Specific commands, music and more + * The Guild Handler handles guild settings and data. * @type {GuildHandler} */ -exports.GuildHandler = class { - constructor(guild, prefix) { +class GuildHandler { + + constructor(guild) { this.guild = guild; - this.dj = null; - this.mention = false; - this.prefix = prefix || config.prefix; - this.servant = new cmd.Servant(this.prefix); + this._logger = new logging.Logger(`${this.constructor.name}@${this.guild}`); + this.musicPlayer = new music.MusicPlayer(null); + this._logger.silly('Initialized Guild Handler'); + this._votes = {}; } + /** + * Initializes the database + * @returns {Promise} + */ async initDatabase() { + this._logger.silly('Initializing Database'); await fs.ensureDir(dataDir + '/gdb'); this.db = new sqliteAsync.Database(`${dataDir}/gdb/${this.guild}.db`); await this.db.init(); - logger.debug(`Connected to the database for ${this.guild}`); - await this.createTables(); - // register commands - this.registerCommands(); + this._logger.debug(`Connected to the database for ${this.guild}`); + this._logger.debug('Creating Databases'); + await this._createTables(); } /** * Destroys the guild handler */ destroy() { - this.dj.stop(); + this._logger.debug('Ending musicPlayer'); + this.musicPlayer.stop(); + this._logger.debug('Ending Database'); this.db.close(); } @@ -53,7 +50,7 @@ exports.GuildHandler = class { * messages - logs all messages send on the server * playlists - save playlists to play them later */ - async createTables() { + async _createTables() { await this.db.run(`${utils.sql.tableExistCreate} messages ( ${utils.sql.pkIdSerial}, creation_timestamp DATETIME NOT NULL, @@ -61,294 +58,45 @@ exports.GuildHandler = class { author_name VARCHAR(128), content TEXT NOT NULL )`); + this._logger.silly('Created Table messages'); await this.db.run(`${utils.sql.tableExistCreate} playlists ( ${utils.sql.pkIdSerial}, name VARCHAR(32) UNIQUE NOT NULL, url VARCHAR(255) NOT NULL )`); + this._logger.silly('Created Table playlists'); await this.db.run(`${utils.sql.tableExistCreate} commands ( ${utils.sql.pkIdSerial}, name VARCHAR(32) UNIQUE NOT NULL, command VARCHAR(255) NOT NULL )`); + this._logger.silly('Created Table commands'); } /** - * Answers a message via mention if mentioning is active or with just sending it to the same channel. - * @param msg - * @param answer - */ - async answerMessage(msg, answer) { - if (answer instanceof Promise || answer) - if (answer instanceof Discord.RichEmbed) { - (this.mention) ? msg.reply('', answer) : msg.channel.send('', answer); - } else if (answer instanceof Promise) { - let resolvedAnswer = await answer; - await this.answerMessage(msg, resolvedAnswer); - } else if (answer instanceof Array) { - await waterfall(answer.map((x) => async () => await this.answerMessage(msg, x))); - } else if ({}.toString.call(answer) === '[object Function]') { // check if the answer is of type function - await this.answerMessage(msg, answer()); - } else { - (this.mention) ? msg.reply(answer) : msg.channel.send(answer); - } - } - - /** - * handles the message by letting the servant parse the command. Depending on the message setting it - * replies or just sends the answer. - * @param msg + * Sets the vote counter for a command up and adds the user. + * @param command {String} + * @param user {String} */ - async handleMessage(msg) { - if (this.db) - await this.db.run( - 'INSERT INTO messages (author, creation_timestamp, author_name, content) values (?, ?, ?, ?)', - [msg.author.id, msg.createdTimestamp, msg.author.username, msg.content] - ); - await this.answerMessage(msg, this.servant.parseCommand(msg)); - } - - /** - * Connect to a voice-channel if not connected and play the url - * @param vc - * @param url - * @param next - */ - async connectAndPlay(vc, url, next) { - if (!this.dj.connected) { - await this.dj.connect(vc); - await this.dj.playYouTube(url, next); - } else { - await this.dj.playYouTube(url, next); + updateCommandVote(command, user) { + if (!this._votes[command]) + this._votes[command] = {count: 0, users: []}; + if (!this._votes[command].users.includes(user)) { + this._votes[command].count++; + this._votes[command].users.push(user); } + return this._votes[command]; } /** - * registers all music commands and initializes a dj + * Resets the vote counter and voted users for a command. + * @param command {String} */ - registerCommands() { - this.dj = new music.DJ(); - - let playCb = async (msg, kwargs, argv, template, next) => { - let vc = this.dj.voiceChannel || msg.member.voiceChannel; - let url = kwargs['url']; - if (!vc) - return template.response.no_voicechannel; - if (!url) - return template.response.no_url; - if (!utils.YouTube.isValidEntityUrl(url)) { - if (argv && argv.length > 0) - url += ' ' + argv.join(' '); // join to get the whole expression behind the command - let row = await this.db.get('SELECT url FROM playlists WHERE name = ?', [url]); - if (!row) { - logger.debug('Got invalid url for play command.'); - return template.response.url_invalid; - } else { - await this.connectAndPlay(vc, row.url, next); - return template.response.success; - } - } else { - await this.connectAndPlay(vc, url, next); - return template.response.success; - } - }; - - // play command - this.servant.createCommand(servercmd.music.play, async (msg, kwargs, argv) => { - return await playCb(msg, kwargs, argv, servercmd.music.play, false); - }); - - // playnext command - this.servant.createCommand(servercmd.music.playnext, async (msg, kwargs, argv) => { - return await playCb(msg, kwargs, argv, servercmd.music.playnext, true); - }); - - // join command - this.servant.createCommand(servercmd.music.join, (msg) => { - if (msg.member.voiceChannel) - this.dj.connect(msg.member.voiceChannel); - else - return servercmd.music.join.response.not_connected; - - }); - - // stop command - this.servant.createCommand(servercmd.music.stop, () => { - if (this.dj.connected) { - this.dj.stop(); - return servercmd.music.stop.response.success; - } else { - return servercmd.music.stop.response.not_playing; - } - }); - - // pause command - this.servant.createCommand(servercmd.music.pause, () => { - if (this.dj.playing) { - this.dj.pause(); - return servercmd.music.pause.response.success; - } else { - return servercmd.music.pause.response.not_playing; - } - }); - - // resume command - this.servant.createCommand(servercmd.music.resume, () => { - if (this.dj.playing) { - this.dj.resume(); - return servercmd.music.resume.response.success; - } else { - return servercmd.music.resume.response.not_playing; - } - }); - - // skip command - this.servant.createCommand(servercmd.music.skip, () => { - if (this.dj.playing) { - this.dj.skip(); - return servercmd.music.skip.response.success; - } else { - return servercmd.music.skip.response.not_playing; - } - - }); - - // clear command - this.servant.createCommand(servercmd.music.clear, () => { - this.dj.clear(); - return servercmd.music.clear.response.success; - }); - - // playlist command - this.servant.createCommand(servercmd.music.playlist, () => { - logger.debug(`found ${this.dj.queue.length} songs`); - let describtion = ''; - for (let i = 0; i < Math.min(this.dj.queue.length, 9); i++) { - let entry = this.dj.queue[i]; - describtion += `[${entry.title}](${entry.url})\n`; - } - return new Discord.RichEmbed() - .setTitle(`${this.dj.queue.length} songs in queue`) - .setDescription(describtion); - }); - - // np command - this.servant.createCommand(servercmd.music.current, () => { - let song = this.dj.song; - if (song) - return new Discord.RichEmbed() - .setTitle('Now playing:') - .setDescription(`[${song.title}](${song.url})`) - .setImage(utils.YouTube.getVideoThumbnailUrlFromUrl(song.url)) - .setColor(0x00aaff); - else - return servercmd.music.current.response.not_playing; - - }); - - // shuffle command - this.servant.createCommand(servercmd.music.shuffle, () => { - this.dj.shuffle(); - return servercmd.music.shuffle.response.success; - }); - - // repeat command - this.servant.createCommand(servercmd.music.repeat, () => { - if (this.dj) { - this.dj.repeat = !this.dj.repeat; - if (this.dj.repeat) - return servercmd.music.repeat.response.repeat_true; - else - return servercmd.music.repeat.response.repeat_false; - } - }); - - // saves playlists and videos - this.servant.createCommand(servercmd.music.savemedia, async (msg, kwargs, argv) => { - let saveName = argv.join(' '); - let row = await this.db.get('SELECT COUNT(*) count FROM playlists WHERE name = ?', [saveName]); - if (!row || row.count === 0) - await this.db.run('INSERT INTO playlists (name, url) VALUES (?, ?)', [saveName, kwargs.url]); - else - await this.db.run('UPDATE playlists SET url = ? WHERE name = ?', [kwargs.url, saveName]); - return `Saved song/playlist as ${saveName}`; - }); - - // savedmedia command - prints out saved playlists and videos - this.servant.createCommand(servercmd.music.savedmedia, async () => { - let response = ''; - let rows = await this.db.all('SELECT name, url FROM playlists'); - for (let row of rows) - response += `[${row.name}](${row.url})\n`; - - if (rows.length === 0) - return servercmd.music.savedmedia.response.no_saved; - else - return new Discord.RichEmbed() - .setTitle('Saved Songs and Playlists') - .setDescription(response) - .setFooter(`Play a saved entry with ${this.prefix}play [Entryname]`) - .setTimestamp(); - }); - - this.servant.createCommand(servercmd.music.deletemedia, async (msg, kwargs, argv) => { - let saveName = argv.join(' '); - if (!saveName) { - return servercmd.music.deletemedia.response.no_name; - } else { - await this.db.run('DELETE FROM playlists WHERE name = ?', [saveName]); - return `Deleted ${saveName} from saved media`; - } - }); - - // savecmd - saves a command sequence with a name - this.servant.createCommand(servercmd.utils.savecmd, async (msg, kwargs, argv) => { - let saveName = argv.pop(); - let cmdsequence = argv.join(' ').replace(/\\/g, ''); - if (argv.includes(this.prefix + servercmd.utils.execute.name)) { - return servercmd.utils.savecmd.response.no_recursion; - } else if (cmdsequence.split(';').length < (config.maxCmdSequenceLength || 5)){ - let row = await this.db.get('SELECT COUNT(*) count FROM commands WHERE name = ?', [saveName]); - if (!row || row.count === 0) - await this.db.run('INSERT INTO commands (name, command) VALUES (?, ?)', [saveName, cmdsequence]); - else - await this.db.run('UPDATE commands SET sequence = ? WHERE name = ?', [cmdsequence, saveName]); - return `saved command sequence as ${saveName}`; - } else { - return servercmd.utils.savecmd.response.sequence_too_long; - } - }); - - // savedcmd - prints saved commands - this.servant.createCommand(servercmd.utils.savedcmd, async () => { - let response = new Discord.RichEmbed() - .setTitle('Saved Commands') - .setFooter(`Execute a saved entry with ${this.prefix}execute [Entryname]`) - .setTimestamp(); - let rows = await this.db.all('SELECT name, command FROM commands'); - if (rows.length === 0) - return servercmd.utils.savedcmd.response.no_commands; - else - for (let row of rows) - response.addField(row.name, '`' + row.command + '`'); - return response; - }); - - // deletecmd - deletes a command from saved commands - this.servant.createCommand(servercmd.utils.deletecmd, async (msg, kwargs) => { - await this.db.run('DELETE FROM commands WHERE name = ?', [kwargs.cmdname]); - return `Deleted command ${kwargs.cmdname}`; - }); - - // execute - executes a saved command - this.servant.createCommand(servercmd.utils.execute, async (msg, kwargs) => { - let row = await this.db.get('SELECT command FROM commands WHERE name = ?', [kwargs.cmdname]); - if (row) { - msg.content = row.command; - await this.handleMessage(msg); - } else { - return servercmd.utils.execute.response.not_found; - } - }); + resetCommandVote(command) { + this._votes[command] = {count: 0, users: []}; } -}; +} + +Object.assign(exports, { + GuildHandler: GuildHandler +}); diff --git a/lib/logging.js b/lib/logging.js index 2ff994a..2278909 100644 --- a/lib/logging.js +++ b/lib/logging.js @@ -1,62 +1,122 @@ /* eslint-disable no-unused-vars */ const winston = require('winston'), DailyRotateFile = require('winston-daily-rotate-file'), - args = require('args-parser')(process.argv), + args = require('args-parser')(process.argv); - fileLoggingFormat = winston.format.printf(info => { - return `${info.timestamp} ${info.level.toUpperCase()}: ${JSON.stringify(info.message)}`; // the logging format for files - }), - consoleLoggingFormat = winston.format.printf(info => { - return `${info.timestamp} [${info.level}] ${JSON.stringify(info.message)}`; //the logging format for the console +/** + * Set console format to simple string format + * @type {Format} + */ +const consoleLoggingFormat = winston.format.printf(info => { + return `${info.timestamp} {${info.module || info.m || 'DEFAULT'}} [${info.level}] ${JSON.stringify(info.message)}`; //the logging format for the console +}); + +/** + * Set full format to combination of formats + * @type {Format} + */ +const loggingFullFormat = winston.format.combine( + winston.format.timestamp({ + format: 'YY-MM-DD HH:mm:ss.SSS' }), - loggingFullFormat = winston.format.combine( - winston.format.splat(), - winston.format.timestamp({ - format: 'YY-MM-DD HH:mm:ss.SSS' - }), - winston.format.json() - ), - logger = winston.createLogger({ - level: winston.config.npm.levels, // logs with npm levels - format: loggingFullFormat, - transports: [ - new winston.transports.Console({ - format: winston.format.combine( - winston.format.colorize(), - winston.format.splat(), - winston.format.timestamp({ - format: 'YY-MM-DD HH:mm:ss.SSS' - }), - consoleLoggingFormat - ), - level: args.loglevel || 'info' - }), - new winston.transports.File({ - level: 'debug', - filename: './.log/latest.log', - options: {flags: 'w'} // overwrites the file on restart + winston.format.json() +); +/** + * Define all transports used. + * @type {any[]} + */ +let transports = [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.splat(), + winston.format.timestamp({ + format: 'YY-MM-DD HH:mm:ss.SSS' }), - new DailyRotateFile({ - level: 'verbose', - filename: './.log/%DATE%.log', - datePattern: 'YYYY-MM-DD', - zippedArchive: true, - maxSize: '32m', - maxFiles: '30d', - json: true - }) - ] - }); + winston.format.label({label: ''}), + consoleLoggingFormat + ), + level: args.loglevel || 'info' + }), + new winston.transports.File({ + level: 'debug', + filename: './.log/latest.log', + options: {flags: 'w'} // overwrites the file on restart + }), + new DailyRotateFile({ + level: 'verbose', + filename: './.log/%DATE%.log', + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '32m', + maxFiles: '30d', + json: true + }) +]; /** - * A function to return the logger that has been created after appending an exception handler - * @returns {Object} + * Define the logger + * @type {winston.Logger} */ -exports.getLogger = function () { - logger.exceptions.handle( - new winston.transports.File({ - filename: './.log/exceptions.log' - }) - ); - return logger; -}; +let logger = winston.createLogger({ + level: winston.config.npm.levels, + format: loggingFullFormat, + transports: transports +}); + + +// Define exception handling +logger.exceptions.handle( + new winston.transports.File({ + filename: './.log/exceptions.log' + }) +); + +class ModuleLogger { + + constructor(moduleInstance) { + this.logger = logger; + if (moduleInstance.constructor) + switch (moduleInstance.constructor.name) { + case 'String': + this.logName = moduleInstance; + break; + case 'Number': + this.logName = moduleInstance.toString(); + break; + default: + this.logName = moduleInstance.constructor.name; + } + else + this.logName = moduleInstance.toString(); + + } + + silly(msg, meta) { + logger.silly(msg, {module: this.logName, ...meta}); + } + + debug(msg, meta) { + logger.debug(msg, {module: this.logName, ...meta}); + } + + verbose(msg, meta) { + logger.verbose(msg, {module: this.logName, ...meta}); + } + + info(msg, meta) { + logger.info(msg, {module: this.logName, ...meta}); + } + warn(msg, meta) { + logger.warn(msg, {module: this.logName, ...meta}); + } + + error(msg, meta) { + logger.error(msg, {module: this.logName, ...meta}); + } +} + +Object.assign(exports, { + logger: logger, + Logger: ModuleLogger +}); diff --git a/lib/utils.js b/lib/utils.js index 3975f2d..620689c 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -11,7 +11,7 @@ function noOp() { * @param {String} filename The name of the file. * @return {String} A string that represents the file-extension. */ -exports.getExtension = function (filename) { +function getFileExtension (filename) { if (!filename) return null; try { @@ -24,7 +24,7 @@ exports.getExtension = function (filename) { console.error(error); return null; } -}; +} /** * Walks the path to the objects attribute and returns the value. @@ -32,7 +32,7 @@ exports.getExtension = function (filename) { * @param attributePath * @returns {undefined/Object} */ -exports.objectDeepFind = function (object, attributePath) { +function objectDeepFind (object, attributePath) { let current = object, paths = attributePath.split('.'); for (let path of paths) @@ -42,7 +42,7 @@ exports.objectDeepFind = function (object, attributePath) { return undefined; return current; -}; +} /** * Shuffles an array with Fisher-Yates Shuffle @@ -74,7 +74,7 @@ exports.shuffleArray = function(array) { * @constructor * @author CanyonCasa & Pier-Luc Gendreau on StackOverflow */ -exports.Cleanup = function Cleanup(callback) { +function Cleanup(callback) { // attach user callback to the process event emitter // if no callback, it will still exit gracefully on Ctrl-C @@ -98,9 +98,9 @@ exports.Cleanup = function Cleanup(callback) { console.log(e.stack); process.exit(99); }); -}; +} -exports.getSplitDuration = function (duration) { +function getSplitDuration (duration) { let dur = duration; let retObj = {}; retObj.milliseconds = dur % 1000; @@ -113,11 +113,23 @@ exports.getSplitDuration = function (duration) { dur = Math.floor(dur / 24); retObj.days = dur; return retObj; -}; +} + +/** + * Resolves a nested promise by resolving it iterative. + * @param promise + * @returns {Promise<*>} + */ +async function resolveNestedPromise (promise) { + let result = await promise; + while (result instanceof Promise) + result = await result; // eslint-disable-line no-await-in-loop + return result; +} /* Classes */ -exports.YouTube = class { +class YouTube { /** * returns if an url is a valid youtube url (without checking for an entity id) * @param url @@ -208,9 +220,9 @@ exports.YouTube = class { let id = exports.YouTube.getVideoIdFromUrl(url); return id? `https://i3.ytimg.com/vi/${id}/maxresdefault.jpg` : null; } -}; +} -exports.ConfigVerifyer = class { +class ConfigVerifyer { /** * @param confObj * @param required {Array} the attributes that are required for the bot to work @@ -243,7 +255,7 @@ exports.ConfigVerifyer = class { logger.error(`Missing required Attributes ${this.missingAttributes.join(', ')}`); } -}; +} exports.sql = { tableExistCreate: 'CREATE TABLE IF NOT EXISTS', @@ -258,3 +270,14 @@ exports.logLevels = { 'warn': 3, 'error:': 4 }; + +Object.assign(exports, { + resolveNestedPromise: resolveNestedPromise, + YouTube: YouTube, + ConfigVerifyer: ConfigVerifyer, + getSplitDuration: getSplitDuration, + getExtension: getFileExtension, + getFileExtension: getFileExtension, + objectDeepFind: objectDeepFind, + Cleanup: Cleanup +}); diff --git a/package.json b/package.json index 7b5d6d7..7714112 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "discordbot", - "version": "1.0.0", + "version": "0.9.1", "scripts": { "start": "node bot.js", "test": "mocha --exit", @@ -23,30 +23,33 @@ "graphql": "14.1.1", "js-md5": "0.7.3", "js-sha512": "0.8.0", + "node-fetch": "^2.3.0", "node-sass": "4.11.0", "opusscript": "0.0.6", "promise-waterfall": "0.1.0", "pug": "2.0.3", "sqlite3": "4.0.6", "winston": "3.2.1", - "winston-daily-rotate-file": "3.6.0", + "winston-daily-rotate-file": "3.8.0", "youtube-playlist-info": "1.1.2", - "ytdl-core": "0.29.1" + "ytdl-core": "0.29.1", + "js-yaml": "latest" }, "devDependencies": { "assert": "1.4.1", "chai": "4.2.0", - "mocha": "5.2.0", - "nyc": "13.2.0", + "mocha": "6.0.2", + "nyc": "13.3.0", "rewire": "4.0.1", - "sinon": "7.2.3", + "sinon": "7.2.6", "eslint-plugin-graphql": "3.0.3", - "eslint": "5.13.0", + "eslint": "5.15.0", "eslint-plugin-promise": "4.0.1" }, "eslintConfig": { "parserOptions": { - "ecmaVersion": 2018 + "ecmaVersion": 2018, + "sourceType": "module" }, "env": { "node": true, diff --git a/test/test.js b/test/test.js index 83d3379..5eb5b33 100644 --- a/test/test.js +++ b/test/test.js @@ -3,7 +3,6 @@ const mockobjects = require('./mockobjects.js'), sinon = require('sinon'), assert = require('assert'), rewire = require('rewire'); -let Discord = require("discord.js"); mockobjects.mockLogger = { error: () => {}, @@ -180,7 +179,7 @@ describe('lib/utils', function() { describe('lib/music', function() { - const music = rewire('../lib/music'); + const music = rewire('../lib/MusicLib'); const Readable = require('stream').Readable; music.__set__("logger", mockobjects.mockLogger); @@ -201,10 +200,10 @@ describe('lib/music', function() { "api": {} }); - describe('#DJ', function () { + describe('#MusicPlayer', function () { it('connects to a VoiceChannel', function (done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.connect().then(()=> { assert(dj.connected); done(); @@ -212,7 +211,7 @@ describe('lib/music', function() { }); it('listens on Repeat', function () { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.current = {'url': '', 'title': ''}; dj.listenOnRepeat = true; @@ -222,7 +221,7 @@ describe('lib/music', function() { it('plays Files', function (done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.connect().then(() => { dj.playFile(); assert(dj.playing); @@ -231,7 +230,7 @@ describe('lib/music', function() { }); it('plays YouTube urls', function (done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.connect().then(() => { dj.playYouTube('http://www.youtube.com/watch?v=ABCDEFGHIJK'); setTimeout(() => { @@ -242,7 +241,7 @@ describe('lib/music', function() { }); it('gets the video name', function (done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.getVideoName('http://www.youtube.com/watch?v=ABCDEFGHIJK').then((name) => { assert(name === 'test'); done(); @@ -250,7 +249,7 @@ describe('lib/music', function() { }); it('sets the volume', function(done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.connect().then(() => { dj.playFile(); dj.setVolume(100); @@ -260,7 +259,7 @@ describe('lib/music', function() { }); it('pauses playback', function(done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.connect().then(() => { dj.playFile(); dj.pause(); @@ -269,7 +268,7 @@ describe('lib/music', function() { }); it('resumes playback', function(done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.connect().then(() => { dj.playFile(); dj.resume(); @@ -278,7 +277,7 @@ describe('lib/music', function() { }); it('stops playback', function(done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.connect().then(() => { dj.playFile(); assert(dj.playing); @@ -289,7 +288,7 @@ describe('lib/music', function() { }); it('skips songs', function(done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.connect().then(() => { dj.playYouTube('http://www.youtube.com/watch?v=ABCDEFGHIJK'); dj.playYouTube('http://www.youtube.com/watch?v=ABCDEFGHIJK'); @@ -302,7 +301,7 @@ describe('lib/music', function() { }); it('returns a playlist', function(done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.connect().then(() => { dj.queue = [{ 'title': 'title', @@ -315,7 +314,7 @@ describe('lib/music', function() { }); it('clears the queue', function(done) { - let dj = new music.DJ(mockobjects.mockVoicechannel); + let dj = new music.MusicPlayer(mockobjects.mockVoicechannel); dj.connect().then(() => { dj.queue = [{ 'title': 'title', @@ -330,163 +329,63 @@ describe('lib/music', function() { }); }); -describe('lib/cmd', function() { - const cmd = rewire('../lib/cmd'); - cmd.__set__("logger", mockobjects.mockLogger); +describe('lib/CommandLib', function() { + let cmdLib = require('../lib/CommandLib'); - describe('#Servant', function() { + describe('Answer', function() { - it('creates commands', function() { - let servant = new cmd.Servant(''); - servant.createCommand(mockobjects.mockCommand, mockobjects.mockCommand.textReply); - assert(servant.commands['test']); - servant.createCommand(mockobjects.mockCommand, mockobjects.mockCommand.promiseReply); - assert(servant.commands['test']); - servant.createCommand(mockobjects.mockCommand, mockobjects.mockCommand.richEmbedReply); - assert(servant.commands['test']); + it('evaluates synchronous', async function() { + let answer = new cmdLib.Answer(() => 'RESPONSE'); + assert((await answer.evaluate({}, {}, {})) === 'RESPONSE'); }); - it('removes commands', function() { - let servant = new cmd.Servant(''); - servant.createCommand(mockobjects.mockCommand, mockobjects.mockCommand.textReply); - assert(servant.commands['test']); - servant.removeCommand('test'); - assert(!servant.commands['test']); - }); - - it('parses commands', function() { - let spy = sinon.spy(); - let servant = new cmd.Servant(''); - servant.createCommand(mockobjects.mockCommand, spy); - assert(servant.commands['test']); - assert(!spy.called); - servant.parseCommand({ - content: 'test', - author: { - tag: undefined - } + it('evaluates asynchronous', async function() { + let answer = new cmdLib.Answer(async () => { + return 'RESPONSE'; }); - assert(spy.called); - }); + assert((await answer.evaluate({}, {}, {})) === 'RESPONSE'); + }) }); -}); -describe('lib/guilding', function*() { // deactivated because of problems with sqlite3 and rewire - const guilding = rewire('../lib/guilding'); - const servercommands = require('../commands/servercommands'); - guilding.__set__("sqliteAsync", null); - guilding.__set__("fs-extra", { - ensureDir: async() => { - return true; - } + describe('Command', function() { + + it('answers with Answer objects', async function() { + let cmd = new cmdLib.Command({ + name: 'TEST', + prefix: '', + description: 'TESTDESCRIPTION', + permission: 'TESTPERM', + usage: 'TESTUSAGE' + },new cmdLib.Answer(() => 'RESPONSE')); + assert((await cmd.answer({}, {}, {})) === 'RESPONSE'); + }); + + it('generates help for itself', function() { + let cmd = new cmdLib.Command({ + name: 'TEST', + prefix: '', + description: 'TESTDESCRIPTION', + permission: 'TESTPERM', + usage: 'TESTUSAGE' + },new cmdLib.Answer(() => 'RESPONSE')); + assert(cmd.help); + }) }); - guilding.setLogger(mockobjects.mockLogger); - - describe('#GuildHandler', function() { - - it('initializes', function() { - let gh = new guilding.GuildHandler('test', ''); - gh.db = new mockobjects.MockDatabase('', ()=>{}); - gh.createTables(); - gh.registerMusicCommands(); - gh.ready = true; - assert(gh.ready); - }); - - it('destroyes itself', function() { - let gh = new guilding.GuildHandler('test', ''); - gh.db = new mockobjects.MockDatabase('', ()=>{}); - gh.createTables(); - gh.registerMusicCommands(); - gh.ready = true; - gh.destroy(); - assert(!gh.dj.conn); - }); - - it('answers messages', function() { - let gh = new guilding.GuildHandler('test', ''); - gh.db = new mockobjects.MockDatabase('', ()=>{}); - gh.createTables(); - gh.registerMusicCommands(); - gh.ready = true; - let msgSpy = sinon.spy(); - gh.answerMessage({ - content: 'test', - author: { - tag: undefined - }, - reply: msgSpy, - channel: { - send: msgSpy - } - }, 'Answer'); - assert(msgSpy.called); - }); - - it('handles messages', function() { - let gh = new guilding.GuildHandler('test', '~'); - gh.db = new mockobjects.MockDatabase('', ()=>{}); - gh.ready = true; - let cbSpy = sinon.spy(); - gh.servant.createCommand(mockobjects.mockCommand, cbSpy); - assert(gh.servant.commands['~test']); - gh.handleMessage({ - content: '~test', - author: { - tag: undefined - }}); - assert(cbSpy.called); - }); - - it('connects and plays', function(done) { - const music = rewire('../lib/music'); - const Readable = require('stream').Readable; - - music.__set__("logger", mockobjects.mockLogger); - music.__set__("yttl", (id, cb) => { - cb(null, 'test'); - }); - music.__set__('ytdl', () => { - let s = new Readable(); - s._read = () => {}; - s.push('chunkofdataabc'); - s.push(null); - return s; - }); - let gh = new guilding.GuildHandler('test', '~'); - gh.db = new mockobjects.MockDatabase('', ()=>{}); - gh.ready = true; - gh.dj = new music.DJ(mockobjects.mockVoicechannel); - gh.connectAndPlay(mockobjects.mockVoicechannel, 'test', false).then(() => { - done(); - }); - }); - - it('handles all servercommands', function() { - let gh = new guilding.GuildHandler('test', '~'); - gh.db = new mockobjects.MockDatabase('', ()=>{}); - gh.registerMusicCommands(); - gh.ready = true; - let msgSpy = sinon.spy(); - let msg = { - content: 'test', - author: { - tag: undefined - }, - reply: msgSpy, - channel: { - send: msgSpy - } - }; - - for (let category of Object.keys(servercommands)) - for (let command of Object.keys(servercommands[category])) { - msg.content = '~' + command; - gh.handleMessage(msg); - } - +}); - assert(msgSpy.called); - }); +describe('lib/MessageLib', function() { + let msgLib = require('../lib/MessageLib'); + + describe('MessageHandler', function() { + it ('parses a command syntax', function() { + let msgHandler = new msgLib.MessageHandler({ + on: () => {} + }); + let parsedSyntax = msgHandler.parseSyntaxString('_help cmd&& _ping; _uptime'); + assert(parsedSyntax.length === 2); + assert(parsedSyntax[0].length === 2); + assert(parsedSyntax[1].length === 1); + }); }); + }); diff --git a/web/http/index.pug b/web/http/index.pug index 145e2c6..95650ea 100644 --- a/web/http/index.pug +++ b/web/http/index.pug @@ -50,30 +50,30 @@ head span.label.text-right Member Count: span#guild-memberCount.text-left .space - h3.cell DJ + h3.cell MusicPlayer .cell span.label.text-right State: - span#guild-djStatus.text-left + span#guild-mpStatus.text-left .cell span.label.text-right Repeat: - span#dj-repeat.text-left + span#mp-repeat.text-left .cell span.label.text-right Voice Channel: - span#dj-voiceChannel.text-left - #dj-songinfo.listContainer(style='display: none') + span#mp-voiceChannel.text-left + #mp-songinfo.listContainer(style='display: none') a#songinfo-container - span#dj-songname - img#dj-songImg(src='' alt='') - #dj-songProgress(style='display:none') - span#dj-songCurrentTS - #dj-queue-container + span#mp-songname + img#mp-songImg(src='' alt='') + #mp-songProgress(style='display:none') + span#mp-songCurrentTS + #mp-queue-container span.cell.label(id='Queue Song count') - span#dj-queueCount + span#mp-queueCount | Songs in Queue span.cell | Next - span#dj-queueDisplayCount 0 + span#mp-queueDisplayCount 0 | Songs: - #dj-songQueue + #mp-songQueue script. startUpdating(); diff --git a/web/http/sass/style.sass b/web/http/sass/style.sass index b8142b6..40bee43 100644 --- a/web/http/sass/style.sass +++ b/web/http/sass/style.sass @@ -247,7 +247,7 @@ div.cell > * #guild-nameAndIcon width: 50% -#dj-songinfo +#mp-songinfo background-color: $cBackgroundVariant border-radius: 20px overflow-x: hidden @@ -259,15 +259,15 @@ div.cell > * padding: 10px width: calc(100% - 20px) -#dj-queue-container +#mp-queue-container display: grid padding: 0 5px 5px -#dj-songname +#mp-songname font-weight: bold font-size: 120% -#dj-songImg +#mp-songImg align-self: center width: 80% height: auto @@ -281,6 +281,6 @@ div.cell > * #guildinfo:hover overflow-y: auto -#dj-songQueue +#mp-songQueue display: grid max-height: 100% diff --git a/web/http/scripts/query.js b/web/http/scripts/query.js index 424abf2..5106bce 100644 --- a/web/http/scripts/query.js +++ b/web/http/scripts/query.js @@ -60,7 +60,7 @@ function queryGuilds() { guilds { id name - dj { + musicPlayer { playing } } @@ -71,8 +71,8 @@ function queryGuilds() { if ($(`option[value=${guild.id}]`).length === 0) { let option = document.createElement('option'); option.setAttribute('value', guild.id); - if (guild.dj) - option.innerText = guild.dj.playing? guild.name + ' 🎶' : guild.name; + if (guild.musicPlayer) + option.innerText = guild.musicPlayer.playing? guild.name + ' 🎶' : guild.name; let guildSelect = document.querySelector('#guild-select'); guildSelect.appendChild(option); } @@ -118,7 +118,7 @@ function queryGuildStatus(guildId) { let query = `{ client { guilds(id: "${guildId}") { - dj { + musicPlayer { playing connected repeat @@ -140,33 +140,32 @@ function queryGuildStatus(guildId) { } } } - config }`; postQuery(query).then((res) => { let guild = res.data.client.guilds[0]; - document.querySelector('#dj-repeat').innerText = guild.dj.repeat? 'on': 'off'; - document.querySelector('#guild-djStatus').innerText = guild.dj.connected? 'connected' : 'disconnected'; - if (guild.dj.connected) { - let songinfoContainer = $('#dj-songinfo'); + document.querySelector('#mp-repeat').innerText = guild.musicPlayer.repeat? 'on': 'off'; + document.querySelector('#guild-mpStatus').innerText = guild.musicPlayer.connected? 'connected' : 'disconnected'; + if (guild.musicPlayer.connected) { + let songinfoContainer = $('#mp-songinfo'); songinfoContainer.show(); - document.querySelector('#guild-djStatus').innerText = guild.dj.playing? 'playing' : 'connected'; - document.querySelector('#dj-voiceChannel').innerText = guild.dj.voiceChannel; + document.querySelector('#guild-mpStatus').innerText = guild.musicPlayer.playing? 'playing' : 'connected'; + document.querySelector('#mp-voiceChannel').innerText = guild.musicPlayer.voiceChannel; - if (guild.dj.playing) { + if (guild.musicPlayer.playing) { if (songinfoContainer.is(':hidden')) songinfoContainer.show(); - document.querySelector('#guild-djStatus').innerText = guild.dj.paused? 'paused' : 'playing'; - document.querySelector('#songinfo-container').setAttribute('href', guild.dj.currentSong.url); - document.querySelector('#dj-songname').innerText = guild.dj.currentSong.name; - document.querySelector('#dj-songImg').setAttribute('src', guild.dj.currentSong.thumbnail.replace('maxresdefault', 'mqdefault')); - let songSd = getSplitDuration(Date.now() - guild.dj.songStartTime); - document.querySelector('#dj-songCurrentTS').innerText = `${songSd.minutes}:${songSd.seconds.toString().padStart(2, '0')}`; - document.querySelector('#dj-songCurrentTS').setAttribute('start-ts', guild.dj.songStartTime); - document.querySelector('#dj-queueCount').innerText = guild.dj.queueCount; - let songContainer = document.querySelector('#dj-songQueue'); + document.querySelector('#guild-mpStatus').innerText = guild.musicPlayer.paused? 'paused' : 'playing'; + document.querySelector('#songinfo-container').setAttribute('href', guild.musicPlayer.currentSong.url); + document.querySelector('#mp-songname').innerText = guild.musicPlayer.currentSong.name; + document.querySelector('#mp-songImg').setAttribute('src', guild.musicPlayer.currentSong.thumbnail.replace('maxresdefault', 'mqdefault')); + let songSd = getSplitDuration(Date.now() - guild.musicPlayer.songStartTime); + document.querySelector('#mp-songCurrentTS').innerText = `${songSd.minutes}:${songSd.seconds.toString().padStart(2, '0')}`; + document.querySelector('#mp-songCurrentTS').setAttribute('start-ts', guild.musicPlayer.songStartTime); + document.querySelector('#mp-queueCount').innerText = guild.musicPlayer.queueCount; + let songContainer = document.querySelector('#mp-songQueue'); $('.songEntry').remove(); - for (let song of guild.dj.queue) { + for (let song of guild.musicPlayer.queue) { let songEntry = document.createElement('a'); songEntry.setAttribute('href', song.url); songEntry.setAttribute('class', 'songEntry'); @@ -179,14 +178,14 @@ function queryGuildStatus(guildId) { songEntry.appendChild(nameEntry); songContainer.appendChild(songEntry); } - document.querySelector('#dj-queueDisplayCount').innerText = document.querySelectorAll('.songEntry').length; + document.querySelector('#mp-queueDisplayCount').innerText = document.querySelectorAll('.songEntry').length; } else { if (songinfoContainer.is(':not(:hidden)')) songinfoContainer.hide(); } } else { - $('#dj-songinfo').hide(); - document.querySelector('#dj-voiceChannel').innerText = 'None'; + $('#mp-songinfo').hide(); + document.querySelector('#mp-voiceChannel').innerText = 'None'; } }); } @@ -241,6 +240,7 @@ function queryLogs(count) { level message timestamp + module } }`; postQuery(query).then((res) => { @@ -257,6 +257,10 @@ function queryLogs(count) { lvlSpan.innerText = logEntry.level; lvlSpan.setAttribute('class', 'text-left'); infoDiv.appendChild(lvlSpan); + let moduleSpan = document.createElement('span'); + moduleSpan.innerText = logEntry.module; + moduleSpan.setAttribute('class', 'text-left'); + infoDiv.appendChild(moduleSpan); let tsSpan = document.createElement('span'); tsSpan.setAttribute('timestamp', logEntry.timestamp); tsSpan.innerText = moment(logEntry.timestamp, 'YY-MM-DD-HH-mm-ss').format('MMM Do HH:mm:ss'); @@ -302,7 +306,7 @@ function startUpdating() { queryGuild(guildId); }); setInterval(() => { - let songSd = getSplitDuration(Date.now() - $('#dj-songCurrentTS').attr('start-ts')); - document.querySelector('#dj-songCurrentTS').innerText = `${songSd.minutes}:${songSd.seconds.toString().padStart(2, '0')}`; + let songSd = getSplitDuration(Date.now() - $('#mp-songCurrentTS').attr('start-ts')); + document.querySelector('#mp-songCurrentTS').innerText = `${songSd.minutes}:${songSd.seconds.toString().padStart(2, '0')}`; }, 500); }