From 42ee8cc4c5339855c6b815eda24e8dcf096131ac Mon Sep 17 00:00:00 2001 From: Trivernis Date: Tue, 26 Feb 2019 20:57:26 +0100 Subject: [PATCH] Added Anilist Api - moved graphql schemas and queries to lib/graphql - added anilistApiLib that contains functions to access the anilist graphql api - added graphql queries to lib/graphql to access data on anilist - added global ~anime command that returns information to an anime - modified help command so that it shows command categories for global and server commands - moved global command registration to lib/cmd --- bot.js | 122 +------- commands/globalcommands.json | 15 +- lib/anilistApiLib.js | 103 +++++++ lib/cmd.js | 264 +++++++++++++++--- lib/graphql/AnilistApi/AnimeById.gql | 47 ++++ lib/graphql/AnilistApi/MangaById.gql | 53 ++++ lib/graphql/AnilistApi/MediaSearchByName.gql | 11 + .../schema.graphql => lib/graphql/schema.gql | 0 lib/weblib.js | 5 +- package.json | 3 +- 10 files changed, 466 insertions(+), 157 deletions(-) create mode 100644 lib/anilistApiLib.js create mode 100644 lib/graphql/AnilistApi/AnimeById.gql create mode 100644 lib/graphql/AnilistApi/MangaById.gql create mode 100644 lib/graphql/AnilistApi/MediaSearchByName.gql rename web/graphql/schema.graphql => lib/graphql/schema.gql (100%) diff --git a/bot.js b/bot.js index 02e1c63..fb6ef09 100644 --- a/bot.js +++ b/bot.js @@ -8,7 +8,6 @@ const Discord = require("discord.js"), 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; @@ -47,6 +46,7 @@ class Bot { */ async initServices() { logger.verbose('Registering cleanup function'); + utils.Cleanup(() => { for (let gh in Object.values(this.guildHandlers)) if (gh instanceof guilding.GuildHandler) @@ -61,6 +61,7 @@ class Bot { this.maindb.close(); }); await this.initializeDatabase(); + if (config.webservice && config.webservice.enabled) await this.initializeWebserver(); logger.verbose('Registering commands'); @@ -76,6 +77,7 @@ class Bot { async start() { await this.client.login(authToken); logger.debug("Logged in"); + if (this.webServer) { this.webServer.start(); logger.info(`WebServer runing on port ${this.webServer.port}`); @@ -92,6 +94,7 @@ class Bot { 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 @@ -161,109 +164,9 @@ 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); - }); + cmd.registerUtilityCommands(prefix, this); + cmd.registerInfoCommands(prefix, this); + cmd.registerAnilistApiCommands(prefix); } /** @@ -292,15 +195,15 @@ class Bot { this.client.on('ready', () => { 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); - }); + }).catch((err) => { + if (err) + logger.warn(err.message); + }); }); this.client.on('message', async (msg) => { @@ -336,6 +239,7 @@ class Bot { this.client.on('voiceStateUpdate', async (oldMember, newMember) => { let gh = await this.getGuildHandler(newMember.guild, prefix); + if (newMember.user === this.client.user) { if (newMember.voiceChannel) gh.dj.updateChannel(newMember.voiceChannel); diff --git a/commands/globalcommands.json b/commands/globalcommands.json index 7ee7886..ce90dbb 100644 --- a/commands/globalcommands.json +++ b/commands/globalcommands.json @@ -80,8 +80,21 @@ "guilds": { "name": "guilds", "permission": "owner", - "description": "Answers with the number of guilds the bot has joined", + "description": "Answers with the number of guilds the bot has joined.", "category": "Info" } + }, + "api": { + "AniList": { + "animeSearch": { + "name": "anime", + "permission": "all", + "description": "Answers the anime found for that name on AniList.", + "category": "AniList", + "response": { + "not_found": "The Anime was not found :(" + } + } + } } } diff --git a/lib/anilistApiLib.js b/lib/anilistApiLib.js new file mode 100644 index 0000000..d1fa6a8 --- /dev/null +++ b/lib/anilistApiLib.js @@ -0,0 +1,103 @@ +const fetch = require('node-fetch'), + fsx = require('fs-extra'), + queryPath = './lib/graphql/AnilistApi', + alApiEndpoint = 'https://graphql.anilist.co'; + +/** + * Return a graphql query read from a file from a configured path. + * @param name + * @returns {Promise<*>} + */ +async function getGraphqlQuery(name) { + return await fsx.readFile(`${queryPath}/${name}.gql`, {encoding: 'utf-8'}); +} + +/** + * 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 + * @returns {Promise} + */ +exports.getAnimeById = async function(id) { + let data = await postGraphqlQuery('AnimeById', {id: id}); + if (data.Media) + return data.Media; + else + return null; +}; + +/** + * Get a manga by id. + * @param id + * @returns {Promise} + */ +exports.getMangaById = async function(id) { + let data = await postGraphqlQuery('MangaById', {id: id}); + if (data.Media) + return data.Media; + else + return null; +}; + +/** + * Search for a media entry by name and return it. + * @param name + * @returns {Promise} + */ +exports.searchMediaByName = async function(name) { + let data = await postGraphqlQuery('MediaSearchByName', {name: name}); + if (data.Media) + return data.Media; + else + return null; +}; + +/** + * Search for an anime by name and get it by id. + * @param name + * @returns {Promise<*>} + */ +exports.searchAnimeByName = async function(name) { + let data = await postGraphqlQuery('MediaSearchByName', {name: name, type: 'ANIME'}); + if (data && data.Media && data.Media.id) + return await exports.getAnimeById(data.Media.id); + else + return null; +}; + +/** + * Search for a manga by name and get it by id. + * @param name + * @returns {Promise<*>} + */ +exports.searchMangaByName = async function(name) { + let data = await postGraphqlQuery('MediaSearchByName', {name: name, type: 'MANGA'}).data; + if (data && data.Media && data.Media.id) + return await postGraphqlQuery('MangaById', {id: data.Media.id}); + else + return null; +}; diff --git a/lib/cmd.js b/lib/cmd.js index a6dd546..2f36c66 100644 --- a/lib/cmd.js +++ b/lib/cmd.js @@ -5,7 +5,8 @@ const Discord = require('discord.js'), args = require('args-parser')(process.argv), config = require('../config.json'), gcmdTempl = require('../commands/globalcommands'), - scmdTempl = require('../commands/servercommands'); + scmdTempl = require('../commands/servercommands'), + utils = require('./utils'); let logger = require('winston'), globCommands = {}; @@ -34,34 +35,8 @@ exports.Servant = class { 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; + let allCommands = {...globCommands, ...this.commands}; + return createHelpEmbed(allCommands, msg, prefix); } }); @@ -217,7 +192,7 @@ exports.parseMessage = function (msg) { * Initializes the module by creating a help command */ exports.init = function (prefix) { - logger.verbose("Created help command"); + logger.verbose("Creating help command..."); this.createGlobalCommand((prefix || config.prefix), gcmdTempl.utils.help, (msg, kwargs) => { if (kwargs.command) { let cmd = kwargs.command; @@ -231,25 +206,19 @@ exports.init = function (prefix) { .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; + return createHelpEmbed(globCommands, msg, prefix); } }); }; +/** + * Processes commands for command series. + * @param cmd + * @param msg + * @param content + * @param returnFunction + * @returns {function(): *} + */ function processCommand(cmd, msg, content, returnFunction) { let argvars = content.match(/(?<= )\S+/g) || []; let kwargs = {}; @@ -306,6 +275,37 @@ function parseGlobalCommand(msg) { } } +/** + * Creates a rich embed that contains help for all commands in the commands object + * @param commands {Object} + * @param msg {module:discord.js.Message} + * @param prefix {String} + * @returns {module:discord.js.RichEmbed} + */ +function createHelpEmbed(commands, msg, prefix) { + let helpEmbed = new Discord.RichEmbed() + .setTitle('Commands') + .setDescription('Create a sequence of commands with `;` (semicolon).') + .setTimestamp(); + let categories = []; + let catCommands = {}; + Object.entries(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; +} + /** * @param msg * @param rolePerm {String} @@ -323,3 +323,177 @@ function checkPermission(msg, rolePerm) { return false; } + +/** + * Registers the bot's utility commands + * @param prefix + * @param bot - the instance of the bot that called + */ +exports.registerUtilityCommands = function(prefix, bot) { + // responde with the commands args + exports.createGlobalCommand(prefix, gcmdTempl.utils.say, (msg, argv, args) => { + return args.join(' '); + }); + + // adds a presence that will be saved in the presence file and added to the rotation + exports.createGlobalCommand(prefix, gcmdTempl.utils.addpresence, async (msg, argv, args) => { + let p = args.join(' '); + this.presences.push(p); + await bot.maindb.run('INSERT INTO presences (text) VALUES (?)', [p]); + return `Added Presence \`${p}\``; + }); + + // shuts down the bot after destroying the client + exports.createGlobalCommand(prefix, gcmdTempl.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 bot.client.destroy(); + logger.debug('Exiting server...'); + } catch (err) { + logger.error(err.message); + logger.debug(err.stack); + } + try { + await bot.webServer.stop(); + logger.debug(`Exiting Process...`); + process.exit(0); + } catch (err) { + logger.error(err.message); + logger.debug(err.stack); + } + }); + + // forces a presence rotation + exports.createGlobalCommand(prefix, gcmdTempl.utils.rotate, () => { + try { + bot.client.clearInterval(this.rotator); + bot.rotatePresence(); + bot.rotator = this.client.setInterval(() => this.rotatePresence(), config.presence_duration); + } catch (error) { + logger.warn(error.message); + } + }); + + exports.createGlobalCommand(prefix, gcmdTempl.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}`); + + bot.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)); + } + }); + }); + + exports.createGlobalCommand(prefix, gcmdTempl.utils.bugreport, () => { + return new Discord.RichEmbed() + .setTitle('Where to report a bug?') + .setDescription(gcmdTempl.utils.bugreport.response.bug_report); + }); +}; + +/** + * Registers the bot's info commands + * @param prefix {String} + * @param bot {Object} + */ +exports.registerInfoCommands = function(prefix, bot) { + // ping command that returns the ping attribute of the client + exports.createGlobalCommand(prefix, gcmdTempl.info.ping, () => { + return `Current average ping: \`${bot.client.ping} ms\``; + }); + + // returns the time the bot is running + exports.createGlobalCommand(prefix, gcmdTempl.info.uptime, () => { + let uptime = utils.getSplitDuration(bot.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 number of guilds, the bot has joined + exports.createGlobalCommand(prefix, gcmdTempl.info.guilds, () => { + return `Number of guilds: \`${bot.client.guilds.size}\``; + }); + + // returns information about the bot + exports.createGlobalCommand(prefix, gcmdTempl.info.about, () => { + return new Discord.RichEmbed() + .setTitle('About') + .setDescription(gcmdTempl.info.about.response.about_creator) + .addField('Icon', gcmdTempl.info.about.response.about_icon); + }); +}; + +/** + * Registers all commands that use the anilist api. + * @param prefix {String} + */ +exports.registerAnilistApiCommands = function(prefix) { + const anilistApi = require('./anilistApiLib'); + + // returns the anime found for the name + exports.createGlobalCommand(prefix, gcmdTempl.api.AniList.animeSearch, async (msg, kwargs, argv) => { + try { + let animeData = await anilistApi.searchAnimeByName(argv.join(' ')); + if (animeData) { + let response = new Discord.RichEmbed() + .setTitle(animeData.title.romaji) + .setDescription(animeData.description.replace(/<\/?.*?>/g, '')) + .setThumbnail(animeData.coverImage.large) + .setURL(animeData.siteUrl) + .setColor(animeData.coverImage.color) + .addField('Genres', animeData.genres.join(', ')) + .setTimestamp(); + if (animeData.studios.studioList.length > 0) + response.addField(animeData.studios.studioList.length === 1? 'Studio' : 'Studios', animeData.studios.studioList.map(x => `[${x.name}](${x.siteUrl})`)); + response.addField('Scoring', `**Average Score:** ${animeData.averageScore} + **Favourites:** ${animeData.favourites}`); + + if (animeData.episodes) + response.addField('Episodes', animeData.episodes); + response.addField('Season', animeData.season); + + if (animeData.startDate.day) + response.addField('Start Date', ` + ${animeData.startDate.day}.${animeData.startDate.month}.${animeData.startDate.year}`); + + if (animeData.nextAiringEpisode) + response.addField('Next Episode', `**Episode** ${animeData.nextAiringEpisode.episode} + **Airing at:** ${new Date(animeData.nextAiringEpisode.airingAt*1000).toUTCString()}`); + + if (animeData.endDate.day) + response.addField('End Date', ` + ${animeData.endDate.day}.${animeData.endDate.month}.${animeData.endDate.year}`); + return response; + } else { + return gcmdTempl.api.AniList.animeSearch.response.not_found; + } + } catch (err) { + if (err.message) { + logger.warn(err.message); + logger.debug(err.stack); + } else { + logger.debug(JSON.stringify(err)); + } + return gcmdTempl.api.AniList.animeSearch.response.not_found; + } + }); +}; diff --git a/lib/graphql/AnilistApi/AnimeById.gql b/lib/graphql/AnilistApi/AnimeById.gql new file mode 100644 index 0000000..b9c5589 --- /dev/null +++ b/lib/graphql/AnilistApi/AnimeById.gql @@ -0,0 +1,47 @@ +query ($id: Int) { + Media (id: $id, type: ANIME) { + id + title { + romaji + english + native + } + status + startDate { + year + month + day + } + endDate { + year + month + day + } + format + season + episodes + duration + genres + siteUrl + coverImage { + large + medium + color + } + description(asHtml: false) + averageScore + favourites + studios(isMain: true) { + studioList: nodes { + id + name + siteUrl + } + } + nextAiringEpisode { + id + airingAt + episode + } + } +} diff --git a/lib/graphql/AnilistApi/MangaById.gql b/lib/graphql/AnilistApi/MangaById.gql new file mode 100644 index 0000000..146bf9b --- /dev/null +++ b/lib/graphql/AnilistApi/MangaById.gql @@ -0,0 +1,53 @@ +query ($id: Int) { + Media (id: $id, type: MANGA) { + id + title { + romaji + english + native + } + status + startDate { + year + month + day + } + endDate { + year + month + day + } + format + chapters + volumes + genres + siteUrl + coverImage { + large + medium + color + } + staff { + edges { + node { + id + name { + first + last + native + } + image { + large + medium + } + language + siteUrl + } + role + } + } + description(asHtml: false) + averageScore + favourites + } +} diff --git a/lib/graphql/AnilistApi/MediaSearchByName.gql b/lib/graphql/AnilistApi/MediaSearchByName.gql new file mode 100644 index 0000000..90a6afb --- /dev/null +++ b/lib/graphql/AnilistApi/MediaSearchByName.gql @@ -0,0 +1,11 @@ +query ($name: String, $type: MediaType) { + Media (search: $name, type: $type) { + id + title { + romaji + english + native + } + type + } +} diff --git a/web/graphql/schema.graphql b/lib/graphql/schema.gql similarity index 100% rename from web/graphql/schema.graphql rename to lib/graphql/schema.gql diff --git a/lib/weblib.js b/lib/weblib.js index 6704709..bd5cef2 100644 --- a/lib/weblib.js +++ b/lib/weblib.js @@ -23,10 +23,13 @@ exports.WebServer = class { 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/graphql/schema.gql', 'utf-8')); this.root = {}; } + /** + * Configures express by setting properties and middleware. + */ configureExpress() { this.app.set('view engine', 'pug'); this.app.set('trust proxy', 1); diff --git a/package.json b/package.json index 81582f1..a0c7e26 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,6 +23,7 @@ "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",