diff --git a/commands/misc/speedrank.js b/commands/misc/speedrank.js new file mode 100644 index 0000000..a5282f5 --- /dev/null +++ b/commands/misc/speedrank.js @@ -0,0 +1,62 @@ +const {Command} = require('discord.js-commando'); +const path = require('path'); +speedrankPath = path.join(__dirname, + '..', '..', 'utilities', 'speedrank-utils.js'); +const speedrankUtils = require('../../utilities/speedrank-utils.js'); + +module.exports = class ReplyCommand extends Command { + /** constructor for looking up soulbreaks. + * @param {Object} client: discord.js-commando client. + **/ + constructor(client) { + super(client, { + name: 'rank', + group: 'misc', + memberName: 'speedrank', + description: 'Looks up all the entries for a given speedrunner in a specified leaderboard' + + ' from the FFRecordKeeper Discord Speedrun challenge leaderboard' + + ' (https://docs.google.com/spreadsheets/d/11gTjAkpm4D3uoxnYCN7ZfbiVnKyi7tmm9Vp9HvTkGpw).', + examples: ['rank EverythingIsGravy 5star', 'top 10 no-csb', 'top 5 cod', 'top 3 Maliris'], + args: [ + { + key: 'name', + prompt: 'Enter the speedrunner you want to query.' + + ' Make sure you use the name as it appears in the spreadsheet.', + type: 'string', + default: 'EverythingIsGravy', + }, + { + key: 'category', + prompt: 'Enter the name of the category you want to look up.' + + ' (Can be "5star", "4star", "3star", or "Torment" ' + + ' on the chart. The default is "4star")', + type: 'string', + default: '4star', + }, + { + key: 'secondaryCategory', + prompt: 'Specify if you want the overall or no-csb ranking' + + ' no-csb does not apply to Torment' + + ' (defaults to "overall".)', + type: 'string', + default: 'overall', + }, + ], + aliases: ['speedrank'], + }); + } + + /** trigger to run upon invocation. + * @param {Object} msg: discord.js-commando message. + * @param {Array} args: args from the user input. + * @return {Method} msg.say: string + **/ + run(msg, args) { + console.log('Im in ur msg'); + const {name, category, secondaryCategory} = args; + return speedrankUtils.speedrank(msg, name, category, secondaryCategory) + .catch( (err) => { + console.log('error in speedrank command:', err); + }); + }; +}; diff --git a/ffrkbot.js b/ffrkbot.js index 68baaa2..f7a83b5 100644 --- a/ffrkbot.js +++ b/ffrkbot.js @@ -18,7 +18,7 @@ const client = new Commando.Client({ client.on('ready', () => { console.log(`Logged in as ${client.user.username}!`); - client.user.setGame(',help'); + client.user.setActivity(',help'); }); client.registry diff --git a/package-lock.json b/package-lock.json index f31c388..e35dbbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -195,7 +195,7 @@ }, "axios": { "version": "0.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz", + "resolved": "http://registry.npmjs.org/axios/-/axios-0.18.0.tgz", "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", "requires": { "follow-redirects": "^1.3.0", @@ -1476,17 +1476,17 @@ } }, "follow-redirects": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.4.1.tgz", - "integrity": "sha1-2BIPRRgZD1Wqxlu2/HuF/NZm1qo=", + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.9.tgz", + "integrity": "sha512-Bh65EZI/RU8nx0wbYF9shkFZlqLP+6WT/5FnA3cE/djNSuKNHJEinGGZgu/cQEkeeb2GdFOgenAmn8qaqYke2w==", "requires": { - "debug": "^3.1.0" + "debug": "=3.1.0" }, "dependencies": { "debug": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha1-W7WgZyYotkFJVmuhaBnmFRjGcmE=", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", "requires": { "ms": "2.0.0" } @@ -1570,8 +1570,8 @@ }, "gcp-metadata": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-0.6.3.tgz", - "integrity": "sha1-RVDAiFnFKLNwRZvXenGH6gvbxKs=", + "resolved": "http://registry.npmjs.org/gcp-metadata/-/gcp-metadata-0.6.3.tgz", + "integrity": "sha512-MSmczZctbz91AxCvqp9GHBoZOSbJKAICV7Ow/AIWSJZRrRchUd5NL1b2P4OfP+4m490BEUPhhARfpHdqCxuCvg==", "requires": { "axios": "^0.18.0", "extend": "^3.0.1", @@ -1710,8 +1710,8 @@ }, "googleapis": { "version": "27.0.0", - "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-27.0.0.tgz", - "integrity": "sha1-whBjO0PnBHtl0z2kDEibbY+cArg=", + "resolved": "http://registry.npmjs.org/googleapis/-/googleapis-27.0.0.tgz", + "integrity": "sha512-Cz0BRsZmewc21N50x5nAUW5cqaGhJ9ETQKZMGqGL4BxmCV7ETELazSqmNi4oCDeRwM4Iub/fIJWAWZk2i6XLCg==", "requires": { "google-auth-library": "^1.3.1", "pify": "^3.0.0", @@ -1732,23 +1732,12 @@ "lodash.isstring": "^4.0.1", "lru-cache": "^4.1.3", "retry-axios": "^0.3.2" - }, - "dependencies": { - "lru-cache": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", - "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", - "requires": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - } } }, "google-p12-pem": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-1.0.2.tgz", - "integrity": "sha1-yKOENQQBIoOg2//HQwt8dT7NSwc=", + "resolved": "http://registry.npmjs.org/google-p12-pem/-/google-p12-pem-1.0.2.tgz", + "integrity": "sha512-+EuKr4CLlGsnXx4XIJIVkcKYrsa2xkAmCvxRhX2HsazJzUBAJ35wARGeApHUn4nNfPD03Vl057FskNr20VaCyg==", "requires": { "node-forge": "^0.7.4", "pify": "^3.0.0" @@ -1756,8 +1745,8 @@ }, "gtoken": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-2.3.0.tgz", - "integrity": "sha1-Tg/8FkMtcEGhs9vB2XqsF6Xclko=", + "resolved": "http://registry.npmjs.org/gtoken/-/gtoken-2.3.0.tgz", + "integrity": "sha512-Jc9/8mV630cZE9FC5tIlJCZNdUjwunvlwOtCz6IDlaiB4Sz68ki29a1+q97sWTnTYroiuF9B135rod9zrQdHLw==", "requires": { "axios": "^0.18.0", "google-p12-pem": "^1.0.0", @@ -1769,27 +1758,12 @@ "mime": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/mime/-/mime-2.3.1.tgz", - "integrity": "sha1-sWIcVNY7l8R9PP5/chX31kUXw2k=" - }, - "node-forge": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.6.tgz", - "integrity": "sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==" + "integrity": "sha512-OEUllcVoydBHGN1z84yfQDimn58pZNNNXgZlHXSboxMlFvgI6MXSWpWKpFRra7H1HxpVhHTkrghfRW49k6yjeg==" }, "pify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=" - }, - "string-template": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string-template/-/string-template-1.0.0.tgz", - "integrity": "sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=" - }, - "uuid": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", - "integrity": "sha1-EsUou51Y0LkmXZovbw/ovhf/HxQ=" } } }, @@ -2803,6 +2777,15 @@ "lower-case": "^1.1.2" } }, + "lru-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", + "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, "makeerror": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", @@ -6419,7 +6402,7 @@ "retry-axios": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/retry-axios/-/retry-axios-0.3.2.tgz", - "integrity": "sha1-V1fID1hbTMTEmGqi/9R6YMbTXhM=" + "integrity": "sha512-jp4YlI0qyDFfXiXGhkCOliBN1G7fRH03Nqy8YdShzGqbY5/9S2x/IR6C88ls2DFkbWuL3ASkP7QD3pVrNpPgwQ==" }, "rewire": { "version": "2.5.2", @@ -6681,6 +6664,11 @@ "strip-ansi": "^3.0.0" } }, + "string-template": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-template/-/string-template-1.0.0.tgz", + "integrity": "sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=" + }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -7012,6 +7000,11 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, "validate-npm-package-license": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz", diff --git a/utilities/speedrank-utils.js b/utilities/speedrank-utils.js new file mode 100644 index 0000000..c17718d --- /dev/null +++ b/utilities/speedrank-utils.js @@ -0,0 +1,303 @@ +const {google} = require('googleapis'); +const util = require('util'); +const fs = require('fs'); +const OAuth2Client = google.auth.OAuth2; +fs.readFileAsync = util.promisify(fs.readFile); +const titlecase = require('titlecase'); +const escapeStringRegexp = require('escape-string-regexp'); +const pad = require('pad'); + +const SPREADSHEET_ID = '11gTjAkpm4D3uoxnYCN7ZfbiVnKyi7tmm9Vp9HvTkGpw'; +const TOKEN_PATH = 'secrets/credentials.json'; +const SECRETS_PATH = 'secrets/client_secret.json'; + +/** authorize(): + * Authorizes Google credentials. + * @return {google.auth.OAuth2} oAuth2Client + **/ +async function authorize() { + let secrets; + let token; + try { + secrets = await fs.readFileAsync(SECRETS_PATH); + token = await fs.readFileAsync(TOKEN_PATH); + const credentials = JSON.parse(secrets); + const {client_secret, client_id, redirect_uris} = credentials.installed; + const oAuth2Client = new OAuth2Client( + client_id, client_secret, redirect_uris[0]); + oAuth2Client.setCredentials(JSON.parse(token)); + return oAuth2Client; + } catch (err) { + console.log('Unable to open SECRETS_PATH or TOKEN_PATH', err); + } +} + +exports.speedrank = function lookupspeedrank( + msg, player, category, secondaryCategory) { + console.log(util.format('.top caller: %s#%s for top %s %s (%s)', + msg.author.username, msg.author.discriminator, + player, category, secondaryCategory)); + return new Promise((resolve, reject) => { + const sheets = google.sheets({version: 'v4'}); + authorize() + .then((oAuth2Client) => { + category = escapeStringRegexp(category); + secondaryCategory = escapeStringRegexp(secondaryCategory); + category = category.toLowerCase(); + const categorySheet = getSheet(category, secondaryCategory); + player = escapeStringRegexp(player); + + const request = { + spreadsheetId: SPREADSHEET_ID, + range: categorySheet, + auth: oAuth2Client, + }; + sheets.spreadsheets.values.get(request, (err, {data}) => { + if (err) { + if (err.code === 400) { + console.log(err.message); + msg.channel.send(`Invalid speedrun category "${category}".`) + .then((res) => { + resolve(res); + return; + }); + } else { + console.log(err); + msg.channel.send( + 'The bot user has not set up valid Google API credentials yet.') + .then((res) => { + resolve(res); + return; + }); + } + } else { + // CategoryNames (headers) are fixed for the rank request + let categoryNames = ['Boss', 'Rank', 'Time']; + + //Fight names are always in Row 2 + const fightRow = data.values[2]; + + let fightNames = []; + for(let i = 1; i < fightRow.length; i++) { + if (fightRow[i] === undefined || + fightRow[i] === '' || + fightRow[i] === null) { + continue; + } + else { + fightNames.push(fightRow[i]); + } + } + + + let contestants = []; + let padLength = 0; + + // Now that we know the fights, let's search each fight column for the player in question + for (let catName of fightNames) { + let categoryRange = find(catName, data.values); + + //TODO: Get max rows of table + for (let i = 0; i < 500; i++) { + // categoryRange.row + 2 will give us the starting row of + // contestants. + let row = categoryRange.row + 2 + i; + //console.log("row = " + row); + // Stop processing if we've hit the end of the list. + if (data.values[row] === undefined) { + break; + } + let contestant = []; + // entryStartPos gives us the starting cell + // with the contestant name. + const entryStartPos = categoryRange.columnNum - 1; + let cell = checkCell(data.values[row][entryStartPos]); + if (catName.length > padLength) { + padLength = catName.length + 1; + console.log(padLength); + } + if (cell.toLowerCase() === player.toLowerCase()) { + contestant.push(catName); + // Grab the actual rank of this row + contestant.push(checkCell(data.values[row][0])); + + // The Overall category and Torment sheets places avg time in the third column. Others have them in the second + if (catName === "Overall" || categorySheet === 'Torment'){ + contestant.push(checkCell(data.values[row][entryStartPos+2])); + } + else { + contestant.push(checkCell(data.values[row][entryStartPos+1])); + } + + contestants.push(contestant); + break; + } + } + } + const rankTable = + outputRankTable(categorySheet, player, + categoryNames, contestants, padLength); + msg.channel.send(rankTable) + .then( (res) => { + resolve(res); + }); + } + }); + }); + }); +}; + +/** getSheet(): + * returns a sheet based on the shorthand input the user provides. + * @param {String} category: the category the user inputs. + * @param {String} secondaryCategory: the secondaryCategory the user inputs. + * @return {String} sheet + **/ +function getSheet(category, secondaryCategory) { + let version = "Overall"; + if (secondaryCategory === 'no-csb'){ + version = 'No CSB'; + } + switch (category) { + case '3star': + category = 'GL 3* '+version+ ' rankings'; + break; + case '4star': + category = 'GL 4* '+version+ ' rankings'; + break; + case '5star': + category = 'GL 5* '+version+ ' rankings'; + break; + case 'torment': + category = 'Torment'; + break; + default: + category = 'Error'; + } + return category; +} + /** + * Finds a value within a given range. + * @see https://stackoverflow.com/questions/10807936/how-do-i-search-google-spreadsheets/10823543#10823543 + * @param {String} value The value to find. + * @param {String} data The range to search in using Google's A1 format. + * @return {Object} A range pointing to the first cell containing the value, + * or null if not found. + */ +function find(value, data) { + for (let i = 0; i < data.length; i++) { + for (let j = 0; j < data[i].length; j++) { + //console.log('Searching:'+data[i][j]); + if (data[i][j] == value) { + const columnName = columnToName(j + 1); + return {row: i + 1, column: columnName, columnNum: j + 1}; + } + } + } + return null; +} +/** + * Returns the Google Spreadsheet column name equivalent of a number. + * @param {Integer} columnNumber The column number to look for + * @return {String} columnName + */ +function columnToName(columnNumber) { + let columnName = ''; + let modulo; + const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + while (columnNumber > 0) { + modulo = (columnNumber - 1) % 26; + columnName = alpha.charAt(modulo) + columnName; + columnNumber = Math.floor((columnNumber - modulo)/26); + } + return columnName; +} + +/** + * Formats a ranking table for output. + * @param {String} category + * @param {String} player + * @param {Array} categoryNames + * @param {Array} contestants + * @param {Integer} namePadLength + * @return {String} table + */ +function outputRankTable(category, player, + categoryNames, contestants, namePadLength) { + let table = ''; + table += outputTitle(category, player); + table += outputCategoryHeader(categoryNames, namePadLength); + table += outputContestants(contestants, namePadLength); + table = util.format('```%s```', table); + return table; +} +/** + * Outputs the title of the table. + * @param {String} category + * @param {String} player + * @return {String} title + */ +function outputTitle(category, player) { + let title = ''; + title = `${player} - ${category}\n`; + return title; +} +/** + * Outputs category headers for table. + * @param {Array} categoryNames + * @param {Integer} namePadLength + * @return {String} categoryHeader + */ +function outputCategoryHeader(categoryNames, namePadLength) { + let categoryHeader = ''; + categoryHeader += pad(categoryNames[0], namePadLength); + categoryHeader += ' | '; + categoryHeader += pad(categoryNames[1], 4); + categoryHeader += ' | '; + categoryHeader += pad(categoryNames[2], 5); + categoryHeader += ' | '; + + const repeatLength = categoryHeader.length - 1; + categoryHeader += '\n'; + // create a series of dashes to indicate header + categoryHeader += '-'.repeat(repeatLength); + categoryHeader += '\n'; + return categoryHeader; +} +/** + * Outputs contestants. + * @param {Array} contestants + * @param {Integer} namePadLength + * @return {String} formattedContestants + */ +function outputContestants(contestants, namePadLength) { + let formattedContestants = ''; + for (let i = 0; i < contestants.length; i++) { + + //boss output + formattedContestants += pad(contestants[i][0], namePadLength); + formattedContestants += ' | '; + //rank output + formattedContestants += pad(contestants[i][1], 4); + formattedContestants += ' | '; + //time output + formattedContestants += pad(contestants[i][2], 5); + formattedContestants += ' | '; + + formattedContestants += '\n'; + } + return formattedContestants; +} +/** + * Ensures null cells don't mess with anything. + * @param cell + * @return {String} cell or '' + */ +function checkCell(cell) { + if (cell === undefined || cell === '' || cell === null) { + return ''; + } + else{ + return cell; + } +}