From 8094d5e6da74f0c27b10fd2e360e4945ffb1ae9e Mon Sep 17 00:00:00 2001 From: Duane Sibilly Date: Mon, 12 Jun 2017 23:44:21 -0400 Subject: [PATCH] Add initial logic and modules --- .gitignore | 1 + Configuration.js.sample | 16 ++++ js/clone.js | 1 + js/db.js | 6 ++ js/get-launches.js | 67 ++++++++++++++++ js/index.js | 161 +++++++++++++++++++++++++++++++++++++++ js/launch-library-api.js | 35 +++++++++ js/wikipedia.js | 37 +++++++++ package.json | 77 +++++++++++++++++++ test/clone.js | 49 ++++++++++++ 10 files changed, 450 insertions(+) create mode 100644 Configuration.js.sample create mode 100644 js/clone.js create mode 100644 js/db.js create mode 100644 js/get-launches.js create mode 100644 js/index.js create mode 100644 js/launch-library-api.js create mode 100644 js/wikipedia.js create mode 100644 package.json create mode 100644 test/clone.js diff --git a/.gitignore b/.gitignore index cb836d3..922c66a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ Configuration.js .DS_Store lib coverage +node_modules diff --git a/Configuration.js.sample b/Configuration.js.sample new file mode 100644 index 0000000..f05f9c9 --- /dev/null +++ b/Configuration.js.sample @@ -0,0 +1,16 @@ +module.exports = { + api: { + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) Jinx/0.1.0' + }, + commandPrefix: '!', + discord: { + token: '' + }, + rethinkdb: { + db: '', + host: '', + password: '', + port: '', + user: '' + } +}; diff --git a/js/clone.js b/js/clone.js new file mode 100644 index 0000000..899a9e4 --- /dev/null +++ b/js/clone.js @@ -0,0 +1 @@ +export default (originalObject, newProperties) => Object.assign({}, originalObject, newProperties); diff --git a/js/db.js b/js/db.js new file mode 100644 index 0000000..97ee685 --- /dev/null +++ b/js/db.js @@ -0,0 +1,6 @@ +import config from '../Configuration'; +import thinky from 'thinky'; + +thinky.init(config.rethinkdb); + +export default thinky; diff --git a/js/get-launches.js b/js/get-launches.js new file mode 100644 index 0000000..46f38b1 --- /dev/null +++ b/js/get-launches.js @@ -0,0 +1,67 @@ +import launchLib, { + args +} from './launch-library-api'; + +import clone from './clone'; +import countdown from 'countdown'; +import Discord from 'discord.js'; +import padStart from 'pad-start'; +import presage from 'presage'; + +const timeToLaunch = date => { + const timespan = countdown(date); + + return `L-${timespan.days === 1 ? + '1 day' : + `${timespan.days} days`}, ${padStart(timespan.hours, 2, 0)}:${padStart(timespan.minutes, 2, 0)}:${padStart(timespan.seconds, 2, 0)}`; + }, + promisify = (client, method, parameters) => { + const { + callbackFunction, + promise + } = presage.promiseWithCallback(); + + client.methods[method](clone(args, { + parameters + }), response => { + callbackFunction(null, response); + }); + + return promise; + }, + nextLaunches = (client, next) => promisify(client, 'launch', { + next + }), + nextLaunch = (client, launchId) => promisify(client, 'launch', { + id: launchId, + mode: 'verbose' + }), + getLaunches = numberOfLaunches => nextLaunches(launchLib, numberOfLaunches).then(response => presage.filter(response.launches, launch => Promise.resolve(!launch.tbddate && !launch.tbdtime))).then(launches => presage.map(launches, launch => nextLaunch(launchLib, launch.id))).then(launches => presage.map(launches, launchData => new Promise(resolve => { + const launchObject = launchData.launches[0]; + + resolve(launchObject); + // resolve(`Mission: ${mission}\nLaunch vehicle: ${rocket}\nLaunch Site: ${launchSite}\nLaunch window opens at ${launchTime} (${timeToLaunch(launchTime)})\nWatch online: ${videoUrl}\n`); + }))); + +export default (bot, message) => new Promise((resolve, reject) => { + getLaunches(1).then(launches => { + const nextLaunch = launches[0], + windowOpens = new Date(nextLaunch.windowstart); + + message.channel.send({ + embed: new Discord.RichEmbed() + .setTitle('Next Rocket Launch') + // .setAuthor(bot.user.username, bot.user.avatarURL) + .setColor(0x00AE86).setThumbnail(nextLaunch.rocket.imageURL) + .setURL(nextLaunch.vidURLs[0]) + .addField('Date', `${windowOpens} (${timeToLaunch(windowOpens)})`) + .addField('Mission', nextLaunch.missions[0].name) + .addField('Launch Vehicle', nextLaunch.rocket.name) + .addField('Launch Site', nextLaunch.location.pads[0].name) + }).then(() => { + resolve(); + }).catch(error => { + reject(error); + }); + }); +}); diff --git a/js/index.js b/js/index.js new file mode 100644 index 0000000..9d25f98 --- /dev/null +++ b/js/index.js @@ -0,0 +1,161 @@ +import Bunyan from 'bunyan'; +import config from '../Configuration'; +import Discord from 'discord.js'; +import getLaunches from './get-launches'; +import wikipedia from './wikipedia'; + +const _log = Bunyan.createLogger({ + name: 'jinx' + }), + aliases = { + nl: 'nextLaunch' + }, + bot = new Discord.Client(), + commands = { + avatar: { + description: 'displays the URL of your full-size Discord avatar', + process: (bot, message) => { + _log.info({ + user: message.author.tag + }, 'Retrieving user avatar'); + return message.channel.send(message.author.avatarURL); + } + }, + nextLaunch: { + description: 'lists the next upcoming rocket launches', + process: (bot, message) => { + _log.info('Retriving next rocket launch information'); + return getLaunches(bot, message); + } + }, + ping: { + description: 'responds pong, useful for checking if bot is alive', + process: (bot, message, suffix) => { + _log.info({ + user: message.author.tag + }, 'Sending ping response'); + return message.channel.send(`${message.author.tag} pong!`).then(() => new Promise((resolve, reject) => { + if (suffix) { + message.channel.send(`${config.commandPrefix}ping takes no arguments...`).then(resolve).catch(reject); + return; + } + + resolve(); + })); + } + }, + wiki: { + description: 'looks up the query string on Wikipedia', + process: (bot, message, suffix) => { + _log.info({ + query: suffix + }, 'Starting Wikipedia search'); + return wikipedia(bot, message, suffix); + } + } + }, + token = config.discord.token; + +bot.on('ready', () => { + _log.info('Jinx Discord bot online!'); +}); + +bot.on('guildMemberAdd', member => { + member.guild.defaultChannel.send(`Welcome to the server, ${member}!`); +}); + +bot.on('message', message => { + if (message.author.id !== bot.user.id && message.content.startsWith(config.commandPrefix)) { + _log.info({ + author: message.author.tag, + content: message.content + }, 'Processing command'); + + let command, + commandText = message.content.split(' ')[0].substring(config.commandPrefix.length), + suffix = message.content.substring(commandText.length + config.commandPrefix.length + 1); + + if (message.isMentioned(bot.user)) { + try { + commandText = message.content.split(' ')[1]; + suffix = message.content.substring(bot.user.mention().length + commandText.length + config.commandPrefix.length + 1); + } catch (exception) { + message.channel.send(`How can I help you, ${message.author.tag}?`); + return; + } + } + + const alias = aliases[commandText]; + + if (alias) { + _log.info({ + alias: commandText, + command: alias, + query: suffix + }, 'Dealiasing command'); + commandText = alias; + suffix = `${suffix}`; + } + + command = commands[commandText]; + + if (commandText === 'help') { + if (suffix) { + message.channel.send(suffix.split(' ').filter(cmd => commands[cmd]).reduce((info, helpCommand) => { + let description = commands[helpCommand].description; + const usage = commands[helpCommand].usage; + + info += `**${config.commandPrefix}${helpCommand}**`; + + if (usage) { + info += ` ${usage}`; + } + + if (description instanceof Function) { + description = description(); + } + + if (description) { + info += `\n\t ${description}`; + } + + info += '\n'; + return info; + }, '')); + } else { + message.author.send(Object.keys(commands).sort().reduce((info, helpCommand) => { + let description = commands[helpCommand].description; + const usage = commands[helpCommand].usage; + + info += `**${config.commandPrefix}${helpCommand}**`; + + if (usage) { + info += ` ${usage}`; + } + + if (description instanceof Function) { + description = description(); + } + + if (description) { + info += `\n\t ${description}`; + } + + info += '\n'; + return info; + }, '**Available Commands:**\n\n')); + } + } else if (command) { + command.process(bot, message, suffix).then(() => { + _log.info({ + author: message.author.tag, + command: commandText + }, 'Command processed'); + }).catch(error => { + message.channel.send(`**Jinx Command Error**: ${commandText} failed!\nStack:\n${error.stack}`); + }); + } + } +}); + +bot.login(token); diff --git a/js/launch-library-api.js b/js/launch-library-api.js new file mode 100644 index 0000000..c6ba6cf --- /dev/null +++ b/js/launch-library-api.js @@ -0,0 +1,35 @@ +import { + Client +} from 'node-rest-client'; + +import config from '../Configuration'; + +const args = { + headers: { + Accept: 'application/json', + 'User-Agent': config.api.userAgent + } + }, + client = new Client(); + +client.registerMethod('agency', 'https://launchlibrary.net/1.2/agency', 'GET'); +client.registerMethod('agencyType', 'https://launchlibrary.net/1.2/agencytype', 'GET'); +client.registerMethod('eventType', 'https://launchlibrary.net/1.2/eventtype', 'GET'); +client.registerMethod('launch', 'https://launchlibrary.net/1.2/launch', 'GET'); +client.registerMethod('launchEvent', 'https://launchlibrary.net/1.2/launchevent', 'GET'); +client.registerMethod('launchStatus', 'https://launchlibrary.net/1.2/launchstatus', 'GET'); +client.registerMethod('location', 'https://launchlibrary.net/1.2/location', 'GET'); +client.registerMethod('mission', 'https://launchlibrary.net/1.2/mission', 'GET'); +client.registerMethod('missionEvent', 'https://launchlibrary.net/1.2/missionevent', 'GET'); +client.registerMethod('missionType', 'https://launchlibrary.net/1.2/missiontype', 'GET'); +client.registerMethod('nextLaunch', 'http://launchlibrary.net/1.2/launch/next/1', 'GET'); +client.registerMethod('pad', 'https://launchlibrary.net/1.2/pad', 'GET'); +client.registerMethod('rocket', 'https://launchlibrary.net/1.2/rocket', 'GET'); +client.registerMethod('rocketEvent', 'https://launchlibrary.net/1.2/rocketevent', 'GET'); +client.registerMethod('rocketFamily', 'https://launchlibrary.net/1.2/rocketfamily', 'GET'); + +export default client; + +export { + args +}; diff --git a/js/wikipedia.js b/js/wikipedia.js new file mode 100644 index 0000000..7f9b6bc --- /dev/null +++ b/js/wikipedia.js @@ -0,0 +1,37 @@ +import Discord from 'discord.js'; +import wiki from 'wikijs'; + +const wikipedia = (bot, message, query) => { + if (!query) { + message.channel.send('usage: !wiki query'); + return Promise.resolve(); + } + + return wiki().search(query, 1).then(data => wiki().page(data.results[0]).then(page => { + page.summary().then(summary => Promise.resolve(summary.toString().split('\n').reduce((text, paragraph) => { + if (text !== '' || !paragraph) { + return text; + } + + if (paragraph) { + return paragraph; + } + + return text; + }, ''))).then(summaryText => { + const pageTitle = page.raw.title, + canonicalUrl = page.raw.canonicalurl; + + return message.channel.send({ + embed: new Discord.RichEmbed() + .setTitle(pageTitle) + .setURL(canonicalUrl) + .setDescription(summaryText) + .setThumbnail('https://upload.wikimedia.org/wikipedia/commons/thumb/f/f6/Wikipedia-logo-v2-wordmark.svg/200px-Wikipedia-logo-v2-wordmark.svg.png') + .setFooter('Data provided by Wikipedia: The Free Encyclopedia') + }); + }); + })); +}; + +export default wikipedia; diff --git a/package.json b/package.json new file mode 100644 index 0000000..c37d748 --- /dev/null +++ b/package.json @@ -0,0 +1,77 @@ +{ + "author": "Duane Sibilly ", + "babel": { + "comments": false, + "plugins": [ + "transform-line", + "transform-runtime" + ], + "presets": [ + "env" + ], + "sourceMaps": "inline" + }, + "dependencies": { + "babel-runtime": "^6.23.0", + "bunyan": "^1.8.10", + "countdown": "^2.6.0", + "discord.js": "^11.1.0", + "node-rest-client": "^3.1.0", + "pad-start": "^1.0.2", + "presage": "^0.1.4", + "thinky": "^2.3.8", + "wikijs": "^3.1.0" + }, + "description": "", + "devDependencies": { + "babel-cli": "^6.24.1", + "babel-istanbul": "^0.12.2", + "babel-plugin-transform-line": "^0.3.0", + "babel-plugin-transform-runtime": "^6.23.0", + "babel-preset-env": "^1.5.2", + "chai": "^4.0.2", + "coveralls": "^2.13.1", + "eslint": "^4.0.0", + "eslint-config-isotropic": "^0.5.0", + "mocha": "^3.4.2", + "nsp": "^2.6.3", + "pre-commit": "^1.2.2", + "sinon": "^2.3.4" + }, + "eslintConfig": { + "env": { + "es6": true, + "mocha": true, + "node": true + }, + "extends": "isotropic", + "parserOptions": { + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "globalReturn": false, + "impliedStrict": true, + "jsx": false + }, + "ecmaVersion": 2017, + "sourceType": "module" + }, + "root": true + }, + "license": "ISC", + "main": "index.js", + "name": "jinx-the-robot", + "scripts": { + "build": "BABEL_ENV=build babel js -d lib", + "coverage": "babel-node ./node_modules/.bin/babel-istanbul cover _mocha --report lcovonly -- -R spec", + "lint": "node_modules/.bin/eslint js", + "posttest": "[ -z \"$npm_config_coverage\" ] || babel-istanbul check-coverage --branches 80 --functions 100 --lines 100 --statements 100", + "prepare": "npm run build", + "prepublishOnly": "npm run securityCheck && npm test --coverage", + "pretest": "npm run lint", + "rebuild": "rm -rf ./node_modules && npm install", + "securityCheck": "nsp check", + "start": "node lib/index.js | bunyan -L", + "test": "babel-node ./node_modules/.bin/babel-istanbul test _mocha" + }, + "version": "0.1.0" +} diff --git a/test/clone.js b/test/clone.js new file mode 100644 index 0000000..3aceda0 --- /dev/null +++ b/test/clone.js @@ -0,0 +1,49 @@ +import { + describe, + it +} from 'mocha'; + +import { + expect +} from 'chai'; + +import clone from '../js/clone'; + +describe('clone', () => { + it('should return an object', () => { + expect(clone({}, {})).to.eql({}); + }); + + it('should merge object properties', () => { + expect(clone({ + foo: 'bar' + }, { + one: 'two' + })).to.eql({ + foo: 'bar', + one: 'two' + }); + }); + + it('should merge object properties in-order', () => { + expect(clone({ + foo: 'bar' + }, { + foo: 'nix' + })).to.eql({ + foo: 'nix' + }); + }); + + it('should provide a copy of the original object', () => { + const originalObject = { + foo: 'bar' + }, + clonedObject = clone(originalObject, {}); + + originalObject.newProp = 'newValue'; + expect(clonedObject).to.eql({ + foo: 'bar' + }); + }); +});