diff --git a/.circleci/config.yml b/.circleci/config.yml index 9188ea1..9f11725 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ jobs: build: docker: # specify the version you desire here - - image: circleci/node:10.11 + - image: circleci/node:10.15 # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images diff --git a/README.md b/README.md index d1ae1bc..169c320 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -discordbot [![CircleCI](https://circleci.com/gh/Trivernis/discordbot.js.svg?style=svg)](https://circleci.com/gh/Trivernis/discordbot.js) [![CodeFactor](https://www.codefactor.io/repository/github/trivernis/discordbot.js/badge)](https://www.codefactor.io/repository/github/trivernis/discordbot.js) +discordbot [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg?style=flat-square)](https://www.gnu.org/licenses/gpl-3.0) [![CircleCI](https://circleci.com/gh/Trivernis/discordbot.js.svg?style=shield)](https://circleci.com/gh/Trivernis/discordbot.js) [![CodeFactor](https://www.codefactor.io/repository/github/trivernis/discordbot.js/badge)](https://www.codefactor.io/repository/github/trivernis/discordbot.js) === A bot that does the discord thing. diff --git a/bot.js b/bot.js index e55a4f9..eec117c 100644 --- a/bot.js +++ b/bot.js @@ -11,8 +11,8 @@ const Discord = require("discord.js"), prefix = args.prefix || config.prefix, gamepresence = args.game || config.presence; -let presences = [], - rotator = null; +let presences = [], // loaded from presences.txt file if the file exists + rotator = null; // an interval id to stop presence duration if needed function main() { utils.Cleanup(() => { @@ -23,6 +23,7 @@ function main() { guilding.setLogger(logger); cmd.init(prefix); registerCommands(); + utils.dirExistence('./data', () => { fs.exists('./data/presences.txt', (exist) => { if (exist) { @@ -37,15 +38,16 @@ function main() { } }) }); - // log the errors instead of letting the program crash - client.on('error', (err) => { - logger.error(err.message); - }); + registerCallbacks(); + client.login(authToken).then(() => { logger.debug("Logged in"); }); } +/** + * registeres global commands + */ function registerCommands() { // useless test command cmd.createGlobalCommand(prefix + 'repeatafterme', (msg, argv, args) => { @@ -106,34 +108,59 @@ function rotatePresence() { logger.debug(`Presence rotation to ${pr}`); } -client.on('ready', () => { - logger.info(`logged in as ${client.user.tag}!`); - client.user.setPresence({game: {name: gamepresence, type: "PLAYING"}, status: 'online'}); -}); - -client.on('message', msg => { - try { - if (msg.author === client.user) { - logger.verbose(`ME: ${msg.content}`); - return; - } - logger.verbose(`<${msg.author.tag}>: ${msg.content}`); - if (!msg.guild) { - let reply = cmd.parseMessage(msg); - if (reply) { - if (reply.isPrototypeOf(Discord.RichEmbed)) { - msg.channel.send('', reply); - } else { - msg.channel.send(reply) - } - } +/** + * Sends the answer recieved from the commands callback. + * Handles the sending differently depending on the type of the callback return + * @param msg + * @param answer + */ +function 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) { + answer + .then((answer) => answerMessage(msg, answer)) + .catch((error) => answerMessage(msg, error)); } else { - guilding.getHandler(msg.guild, prefix).handleMessage(msg); + (this.mention)? msg.reply(answer) : msg.channel.send(answer); } - } catch (err) { - logger.error(err.stack); + } else { + logger.warn(`Empty answer won't be send.`); } -}); +} + +/** + * Registeres callbacks for client events + */ +function registerCallbacks() { + client.on('error', (err) => { + logger.error(err.message); + }); + + client.on('ready', () => { + logger.info(`logged in as ${client.user.tag}!`); + client.user.setPresence({game: {name: gamepresence, type: "PLAYING"}, status: 'online'}); + }); + + client.on('message', msg => { + try { + if (msg.author === client.user) { + logger.verbose(`ME: ${msg.content}`); + return; + } + logger.verbose(`<${msg.author.tag}>: ${msg.content}`); + if (!msg.guild) { + let reply = cmd.parseMessage(msg); + answerMessage(msg, reply); + } else { + guilding.getHandler(msg.guild, prefix).handleMessage(msg); + } + } catch (err) { + logger.error(err.stack); + } + }); +} // Executing the main function if (typeof require !== 'undefined' && require.main === module) { diff --git a/commands/servercommands.json b/commands/servercommands.json index b150023..e1c26ab 100644 --- a/commands/servercommands.json +++ b/commands/servercommands.json @@ -103,7 +103,10 @@ "name": "np", "permission": "all", "description": "Shows the currently playing song.", - "category": "Music" + "category": "Music", + "response": { + "not_playing": "I'm not playing music at the moment." + } }, "shuffle": { "name": "shuffle", diff --git a/lib/cmd.js b/lib/cmd.js index 1c4cbb3..ea608b1 100644 --- a/lib/cmd.js +++ b/lib/cmd.js @@ -1,16 +1,14 @@ /* Module definition */ /* Variable Definition */ -let logger = require('winston'), - globCommands = {}, - ownerCommands = {}, - config = require('../config.json'), +const Discord = require('discord.js'), args = require('args-parser')(process.argv), + config = require('../config.json'), gcmdTempl = require('../commands/globalcommands'), - scmdTempl = require('../commands/servercommands'), - Discord = require('discord.js'); + scmdTempl = require('../commands/servercommands'); -/* Function Definition */ +let logger = require('winston'), + globCommands = {}; /** * @type {Servant} diff --git a/lib/guilding.js b/lib/guilding.js index a08265c..555fb09 100644 --- a/lib/guilding.js +++ b/lib/guilding.js @@ -92,7 +92,7 @@ exports.GuildHandler = class { (this.mention)? msg.reply(answer) : msg.channel.send(answer); } } else { - logger.warning(`Empty answer won't be send.`); + logger.warn(`Empty answer won't be send.`); } } /** @@ -130,7 +130,7 @@ exports.GuildHandler = class { this.dj.connect(vc).then(() => { this.dj.playYouTube(url, next); resolve(); - }); + }).catch((err) => reject(err)); } else { this.dj.playYouTube(url, next); resolve(); @@ -155,37 +155,33 @@ exports.GuildHandler = class { reject(servercmd.music.play.response.no_voicechannel); if (!url) reject(servercmd.music.play.response.no_url); - if (!url.match(/http/g)) { + if (!utils.YouTube.isValidEntityUrl(url)) { if (argv && argv.length > 0) url += ' ' + argv.join(' '); // join to get the whole expression behind the command this.db.get('SELECT url FROM playlists WHERE name = ?', [url], (err, row) => { - if (err) { + if (err) console.error(err.message); - } if (!row) { reject(servercmd.music.play.response.url_invalid); logger.verbose('Got invalid url for play command.'); } else { url = row.url; - try { - this.connectAndPlay(vc, url).then(() => { - resolve(servercmd.music.play.response.success); - }); - } catch (err) { + + this.connectAndPlay(vc, url).then(() => { + resolve(servercmd.music.play.response.success); + }).catch((err) => { logger.error(err.message); reject(servercmd.music.play.response.failure); - } + }); } }); } else { - try { - this.connectAndPlay(vc, url).then(() => { - resolve(servercmd.music.play.response.success); - }); - } catch (err) { + this.connectAndPlay(vc, url).then(() => { + resolve(servercmd.music.play.response.success); + }).catch((err) => { logger.error(err.message); reject(servercmd.music.play.response.failure); - } + }); } }) }); @@ -197,35 +193,32 @@ exports.GuildHandler = class { if (!this.dj.connected) this.dj.voiceChannel = vc; let url = kwargs['url']; if (!url) reject(servercmd.music.playnext.response.no_url); - if (!url.match(/http/g)) { + if (!utils.YouTube.isValidEntityUrl(url)) { if (argv) url += ' ' + argv.join(' '); this.db.get('SELECT url FROM playlists WHERE name = ?', [url], (err, row) => { - if (err) { + if (err) console.error(err.message); - } if (!row) { reject(servercmd.music.play.response.url_invalid); - } - url = row.url; - try { + } else { + url = row.url; + this.connectAndPlay(url, true).then(() => { resolve(servercmd.music.playnext.response.success); + }).catch((err) => { + logger.error(err.message); + reject(servercmd.music.play.response.failure); }); - } catch (err) { - logger.error(err.message); - reject(servercmd.music.play.response.failure); } }); } else { - try { - this.connectAndPlay(url, true).then(() => { - resolve(servercmd.music.playnext.response.success); - }); - } catch (err) { + this.connectAndPlay(url, true).then(() => { + resolve(servercmd.music.playnext.response.success); + }).catch((err) => { logger.error(err); reject(servercmd.music.playnext.response.failure); - } + }); } }) }); @@ -284,7 +277,15 @@ exports.GuildHandler = class { // np command this.servant.createCommand(servercmd.music.current, () => { let song = this.dj.song; - return `Playing: ${song.title}\n ${song.url}`; + 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 diff --git a/lib/music.js b/lib/music.js index e00e2bd..47b4069 100644 --- a/lib/music.js +++ b/lib/music.js @@ -4,6 +4,7 @@ const Discord = require("discord.js"), yttl = require('get-youtube-title'), args = require('args-parser')(process.argv), config = require('../config.json'), + utils = require('./utils.js'), ytapiKey = args.ytapi || config.ytapikey; /* Variable Definition */ let logger = require('winston'); @@ -46,6 +47,17 @@ exports.DJ = class { }) } + /** + * Defining setter for listenOnRepeat to include the current song into the repeating loop. + * @param value + */ + set listenOnRepeat(value) { + this.repeat = value; + if (this.current) { + this.queue.push(this.current); + } + } + /** * Returns if a connection exists * @returns {boolean} @@ -100,84 +112,83 @@ exports.DJ = class { * @param playnext */ playYouTube(url, playnext) { - /** Commented because it causes an connection overflow error. - * TODO: Decide to either fix this with promises or ignore it because connection checks are performed by the guild handler.**/ - /* - if (!this.connected) { - this.connect().then(this.playYouTube(url)); - } - */ - let plist = url.match(/(?<=\?list=)[\w\-]+/); + let plist = utils.YouTube.getPlaylistIdFromUrl(url); if (plist) { logger.debug(`Adding playlist ${plist} to queue`); ypi(ytapiKey, plist).then(items => { - for (let i = 0; i < items.length; i++) { - let vurl = `https://www.youtube.com/watch?v=${items[i].resourceId.videoId}`; - this.queue.push({'url': vurl, 'title': null}); - yttl(vurl.replace(/http(s)?:\/\/(www.)?youtube.com\/watch\?v=/g, ''), (err, title) => { - if (err) { - logger.debug(JSON.stringify(err)); - } else { - try { - logger.debug(`Found title: ${title} for ${vurl}`); - this.queue.find((el) => { - return (el.url === vurl); - }).title = title; - } catch (error) { - logger.verbose(JSON.stringify(error)); - } - } - }); + let firstSong = utils.YouTube.getVideoUrlFromId(items.shift().resourceId.videoId); + + this.getVideoName(firstSong).then((title) => { // getting the first song to start playing music + if (this.repeat) // listen on repeat + this.queue.push({'url': firstSong, 'title': title}); // put the current song back at the end of the queue + this.playYouTube(firstSong); // call with single url that gets queued if a song is already playing + }).catch((err) => logger.error(err.message)); + for (let item of items) { + let vurl = utils.YouTube.getVideoUrlFromId(item.resourceId.videoId); + this.getVideoName(vurl).then((title) => { + this.queue.push({'url': vurl, 'title': title}); + }).catch((err) => logger.error(err.message)); } - this.current = this.queue.shift(); - if (this.repeat) this.queue.push(this.current); - this.playYouTube(this.current.url); + logger.debug(`Added ${items.length} songs to the queue`); }); - return; - } - if (!this.playing || !this.disp) { - logger.debug(`Playing ${url}`); - this.disp = this.conn.playStream(ytdl(url, { - filter: 'audioonly', - quality: this.quality, - liveBuffer: 40000 - }), {volume: this.volume}); - this.disp.on('end', (reason) => { - if (reason !== 'stop') { - this.playing = false; - this.current = null; - if (this.queue.length > 0) { - this.current = this.queue.shift(); - if (this.repeat) this.queue.push(this.current); - this.playYouTube(this.current.url); - } else { - this.stop(); - } - } - }); - this.playing = true; } else { - logger.debug(`Added ${url} to the queue`); - if (playnext) { - this.queue.unshift({'url': url, 'title': null}); + if (!this.playing || !this.disp) { + logger.debug(`Playing ${url}`); + + this.getVideoName(url).then((title) => { + this.current = ({'url': url, 'title': title}); + + this.disp = this.conn.playStream(ytdl(url, { + filter: 'audioonly', quality: this.quality, liveBuffer: 40000 + }), {volume: this.volume}); + + this.disp.on('end', (reason) => { // end event triggers the next song to play when the reason is not stop + if (reason !== 'stop') { + this.playing = false; + this.current = null; + if (this.queue.length > 0) { + this.current = this.queue.shift(); + if (this.repeat) // listen on repeat + this.queue.push(this.current); + this.playYouTube(this.current.url); + } else { + this.stop(); + } + } + }); + this.playing = true; + }); } else { - this.queue.push({'url': url, 'title': null}); + logger.debug(`Added ${url} to the queue`); + if (playnext) { + this.getVideoName(url).then((title) => { + this.queue.unshift({'url': url, 'title': title}); + }).catch((err) => logger.error(err.message)); + } else { + this.getVideoName(url).then((title) => { + this.queue.push({'url': url, 'title': title}); + }).catch((err) => logger.error(err.message)); + } } - yttl(url.replace(/http(s)?:\/\/(www.)?youtube.com\/watch\?v=/g, ''), (err, title) => { + } + } + + /** + * Gets the name of the YouTube Video at url + * @param url + * @returns {Promise<>} + */ + getVideoName(url) { + return new Promise((resolve, reject) => { + yttl(utils.YouTube.getVideoIdFromUrl(url), (err, title) => { if (err) { logger.debug(JSON.stringify(err)); + reject(err); } else { - try { - logger.debug(`Found title: ${title} for ${url}`); - this.queue.find((el) => { - return (el.url === url); - }).title = title; - } catch (error) { - console.verbose(JSON.stringify(error)); - } + resolve(title); } }); - } + }); } /** diff --git a/lib/utils.js b/lib/utils.js index a28e04b..d9333e7 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -71,4 +71,86 @@ exports.dirExistence = function (path, callback) { callback(); } }) +}; + +exports.YouTube = class { + /** + * returns if an url is a valid youtube url (without checking for an entity id) + * @param url + * @returns {boolean} + */ + static isValidUrl(url) { + return /https?:\/\/www.youtube.com\/(watch\?v=|playlist\?list=)/g.test(url) || + /https?:\/\/youtu.be\//g.test(url); + } + + /** + * returns if an url is a valid youtube url for an entity + * @param url + * @returns {boolean} + */ + static isValidEntityUrl(url) { + return /https?:\/\/www.youtube.com\/(watch\?v=.+?|playlist\?list=.+?)/g.test(url) || + /https?:\/\/youtu.be\/.+?/g.test(url); + } + + /** + * Returns if an url is a valid youtube url for a playlist + * @param url + * @returns {boolean} + */ + static isValidPlaylistUrl(url) { + return /https?:\/\/www.youtube.com\/playlist\?list=.+?/g.test(url); + } + + /** + * Returns if an url is a valid youtube url for a video + * @param url + * @returns {boolean} + */ + static isValidVideoUrl(url) { + return /https?:\/\/www.youtube.com\/watch\?v=.+?/g.test(url) || /https?:\/\/youtu.be\/.+?/g.test(url); + } + + /** + * Returns the id for a youtube video stripped from the url + * @param url + * @returns {RegExpMatchArray} + */ + static getPlaylistIdFromUrl(url) { + let matches = url.match(/(?<=\?list=)[\w\-]+/); + if (matches) + return matches[0]; + else + return null; + } + + /** + * Returns the id for a youtube video stripped from the url + * @param url + */ + static getVideoIdFromUrl(url) { + let matches = url.match(/(?<=\?v=)[\w\-]+/); + if (matches) + return matches[0]; + else + return null; + } + + /** + * Returns the youtube video url for a video id by string concatenation + * @param id + */ + static getVideoUrlFromId(id) { + return `https://www.youtube.com/watch?v=${id}`; + } + + /** + * Returns the youtube video thumbnail for a video url + * @param url + * @returns {string} + */ + static getVideoThumbnailUrlFromUrl(url) { + return `https://i3.ytimg.com/vi/${exports.YouTube.getVideoIdFromUrl(url)}/maxresdefault.jpg` + } }; \ No newline at end of file