diff --git a/bot.js b/bot.js index 4bcf2f2..d6b9b4c 100644 --- a/bot.js +++ b/bot.js @@ -72,7 +72,11 @@ class Bot { await this.messageHandler .registerCommandModule(require('./lib/commands/UtilityCommands').module, {bot: this, logger: logger, config: config}); await this.messageHandler - .registerCommandModule(require('./lib/commands/InfoCommands').module, {client: this.client}); + .registerCommandModule(require('./lib/commands/InfoCommands').module, {client: this.client, messageHandler: this.messageHandler}); + await this.messageHandler + .registerCommandModule(require('./lib/commands/MusicCommands').module, {getGuildHandler: (g) => { + return this.getGuildHandler(g, prefix); + }, logger: logger}) //this.registerCommands(); this.registerCallbacks(); cmd.init(prefix); diff --git a/lib/CommandLib.js b/lib/CommandLib.js index 07ad8e1..280d980 100644 --- a/lib/CommandLib.js +++ b/lib/CommandLib.js @@ -46,12 +46,13 @@ class Command { */ 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.name} [${this.args.join('][')}]`.replace('[]', ''); this.answObj = answer; if (!template.name) throw new Error("Template doesn't define a name."); @@ -76,7 +77,7 @@ class Command { get help() { return new ExtendedRichEmbed(`Help for ${this.name}`) .addFields({ - 'Usage': this.usage, + 'Usage': `\`${this.prefix}${this.usage}\``, 'Description': this.description, 'Permission Role': this.permission }); @@ -109,13 +110,16 @@ class CommandHandler { 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) { let kwargs = {}; if (args) - for (let i = 0; i < Math.min(command.kwargs, args.length); i++) - kwargs[command.kwargs[i]] = args[i]; + 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 { return false; @@ -131,7 +135,9 @@ class CommandHandler { * @param command {Command} */ registerCommand(command) { + command.prefix = this.prefix; this.commands[command.name] = command; + return this; } } @@ -184,19 +190,23 @@ class ExtendedRichEmbed extends Discord.RichEmbed { * 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) 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; } } diff --git a/lib/MessageLib.js b/lib/MessageLib.js index 8545021..5b16d9e 100644 --- a/lib/MessageLib.js +++ b/lib/MessageLib.js @@ -7,9 +7,9 @@ class MessageHandler { /** * Message Handler to handle messages. Listens on the - * client message event. + * _client message event. * @param client {Discord.Client} - * @param logger {winston.logger} + * @param logger {winston._logger} */ constructor (client, logger) { this.logger = logger; @@ -100,7 +100,7 @@ class MessageHandler { */ async _executeCommandSequence(cmdSequence, message) { this.logger.debug(`Executing command sequence: ${JSON.stringify(cmdSequence)} ...`); - let scopeCmdHandler = this._getScopeHandler(message); + let scopeCmdHandler = this.getScopeHandler(message); await Promise.all(cmdSequence.map((sq) => promiseWaterfall(sq.map((cmd) => async () => { try { this.logger.debug(`Executing command ${cmd}`); @@ -124,7 +124,7 @@ class MessageHandler { * @param message * @private */ - _getScopeHandler(message) { + getScopeHandler(message) { if (message && message.guild) return this.guildCmdHandler; else diff --git a/lib/commands/AnilistApiCommands/AniListCommandsTemplate.yaml b/lib/commands/AnilistApiCommands/AniListCommandsTemplate.yaml index 400d567..df61a40 100644 --- a/lib/commands/AnilistApiCommands/AniListCommandsTemplate.yaml +++ b/lib/commands/AnilistApiCommands/AniListCommandsTemplate.yaml @@ -3,8 +3,8 @@ anime_search: permission: all usage: anime [search query] description: > - Searches AniList.co for the anime Title and returns information about - it if there is an result. + Searches AniList.co for the anime title and returns information about + it if there is a result. category: AniList response: not_found: > @@ -15,8 +15,8 @@ manga_search: permission: all usage: manga [search query] description: > - Searches AniList.co for the manga Title and returns information about - it if there is an result. + Searches AniList.co for the manga title and returns information about + it if there is a result. category: AniList response: not_found: > diff --git a/lib/commands/InfoCommands/InfoCommandsTemplate.yaml b/lib/commands/InfoCommands/InfoCommandsTemplate.yaml index 247603a..893754c 100644 --- a/lib/commands/InfoCommands/InfoCommandsTemplate.yaml +++ b/lib/commands/InfoCommands/InfoCommandsTemplate.yaml @@ -32,3 +32,12 @@ guilds: 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 + args: + - command diff --git a/lib/commands/InfoCommands/index.js b/lib/commands/InfoCommands/index.js index 3063fe5..5caee38 100644 --- a/lib/commands/InfoCommands/index.js +++ b/lib/commands/InfoCommands/index.js @@ -4,7 +4,7 @@ const cmdLib = require('../../CommandLib'), /** * Info commands provide information about the bot. These informations are - * not process specific but access the discord client instance of the bot. + * not process specific but access the discord _client instance of the bot. */ class InfoCommandModule extends cmdLib.CommandModule { @@ -12,11 +12,33 @@ 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._client = opts.client; + this._messageHandler = opts.messageHandler; + } + + _createHelpEmbed(commands, msg, prefix) { + let helpEmbed = new cmdLib.ExtendedRichEmbed('Commands') + .setDescription('Create a sequence of commands with `;` and `&&`.'); + 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'); + return helpEmbed; } async register(commandHandler) { @@ -34,14 +56,14 @@ class InfoCommandModule extends cmdLib.CommandModule { let ping = new cmdLib.Command( this.template.ping, new cmdLib.Answer(() => { - return `Current average ping: \`${this.client.ping} ms\``; + 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); + let uptime = utils.getSplitDuration(this._client.uptime); return new cmdLib.ExtendedRichEmbed('Uptime').setDescription(` **${uptime.days}** days **${uptime.hours}** hours @@ -55,15 +77,33 @@ class InfoCommandModule extends cmdLib.CommandModule { let guilds = new cmdLib.Command( this.template.guilds, new cmdLib.Answer(() => { - return `Number of guilds: \`${this.client.guilds.size}\``; + 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; + } else { + let commandObj = {...globH.commands, ...scopeH.commands}; + return this._createHelpEmbed(commandObj, m, globH.prefix); + } }) ); // register commands - commandHandler.registerCommand(about); - commandHandler.registerCommand(ping); - commandHandler.registerCommand(uptime); - commandHandler.registerCommand(guilds); + commandHandler + .registerCommand(about) + .registerCommand(ping) + .registerCommand(uptime) + .registerCommand(guilds) + .registerCommand(help); } } diff --git a/lib/commands/MusicCommands/MusicCommandsTemplate.yaml b/lib/commands/MusicCommands/MusicCommandsTemplate.yaml new file mode 100644 index 0000000..cdfb709 --- /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: dj + 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: dj + 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: dj + 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..68e8c64 --- /dev/null +++ b/lib/commands/MusicCommands/index.js @@ -0,0 +1,285 @@ +const cmdLib = require('../../CommandLib'), + utils = require('../../utils'), + location = './lib/commands/MusicCommands'; + +/** + * 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. + * logger - the logger instance + */ + constructor(opts) { + super(cmdLib.CommandScopes.Guild); + this.templateFile = location + '/MusicCommandsTemplate.yaml'; + this._getGuildHandler = opts.getGuildHandler; + this._logger = opts.logger; + } + + /** + * 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.dj.connected) { + await gh.dj.connect(vc); + await gh.dj.playYouTube(url, next); + } else { + await gh.dj.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.dj.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.dj.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); + if (gh.dj.connected) { + gh.dj.stop(); + return this.template.stop.success; + } 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.dj.playing) { + gh.dj.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.dj.playing) { + gh.dj.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); + if (gh.dj.playing) { + gh.dj.skip(); + return this.template.skip.response.success; + } 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.dj.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.dj.queue.length} songs.`); + let description = ''; + + for (let i = 0; i < Math.min(gh.dj.queue.length, 9); i++) { + let entry = gh.dj.queue[i]; + description += `[${entry.title}](${entry.url})\n`; + } + return new cmdLib.ExtendedRichEmbed(`${gh.dj.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.dj.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.dj.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.dj.repeat = !gh.dj.repeat; + return gh.dj.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/UtilityCommands/UtilityCommandsTemplate.yaml b/lib/commands/UtilityCommands/UtilityCommandsTemplate.yaml index 09cab2c..488a730 100644 --- a/lib/commands/UtilityCommands/UtilityCommandsTemplate.yaml +++ b/lib/commands/UtilityCommands/UtilityCommandsTemplate.yaml @@ -14,14 +14,14 @@ add_presence: usage: addpresence [presence] rotate_presence: - name: rotate_presence + name: rotatepresence description: > Forces a presence rotation permission: owner category: Utility create_user: - name: create_user + name: createuser description: > Creates a user for the webinterface. permission: owner