diff --git a/package.json b/package.json index d4914dc..55af9be 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "author": "chrisedwards82@gmail.com", "license": "LicenseRef-LICENSE", "devDependencies": { + "aws-sdk": "^2.100.0", "chai": "^3.5.0", "coveralls": "^2.11.15", "dotenv": "^4.0.0", diff --git a/src/aws.js b/src/aws.js new file mode 100644 index 0000000..6db7359 --- /dev/null +++ b/src/aws.js @@ -0,0 +1,25 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +const AWS = require('aws-sdk'); + +module.exports = { + + getLambdaClient(config = {}) { + if (!this.lambdaClient) { + this.lambdaClient = new AWS.Lambda(config) + } + return this.lambdaClient; + }, + + invokeLambda(params) { + const lambdaClient = this.getLambdaClient(); + return new Promise((resolve, reject) => { + lambdaClient.invoke(params, (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + } +}; diff --git a/src/commands.js b/src/commands.js index 7fab7dc..fb56ffa 100644 --- a/src/commands.js +++ b/src/commands.js @@ -1,5 +1,5 @@ const request = require('request-promise-native'); - +const radio = require('./radio'); const { CMD_NOT_SUPPORTED, NOW_PLAYING, @@ -281,7 +281,7 @@ function say(text) { }) } -function exec({ text, user_name, command }) { +function exec({ text, user_name, command, response_url }) { let error; @@ -328,6 +328,9 @@ function exec({ text, user_name, command }) { return setPlaylist(text); } return getPlaylist(); + case '/radio': + return radio + .createRadioStation(text, response_url); default: error = new Error(CMD_NOT_SUPPORTED); error.statusCode = 400; diff --git a/src/index.js b/src/index.js index bebe7d7..26abc8a 100755 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,6 @@ const { parseFormString } = require('./util/parse'); const { exec, setAPIRoot } = require('./commands'); +const radio = require('./radio'); const { slackResp, INVALID_TOKEN, @@ -11,13 +12,16 @@ function handler(event, context, callback) { const { SLACK_TOKEN, - SPOTIFY_LOCAL_URL + SPOTIFY_LOCAL_URL, + SPOTIFY_USER_ACCESS_TOKEN, + RADIO_LAMBDA } = process.env; const { command, text, token, + response_url, user_name } = parseFormString(event.body); @@ -31,7 +35,9 @@ function handler(event, context, callback) { ); } else { setAPIRoot(SPOTIFY_LOCAL_URL); - exec({ command, text, user_name }) + radio.setAccessToken(SPOTIFY_USER_ACCESS_TOKEN); + radio.setFunctionARN(RADIO_LAMBDA); + exec({ command, text, user_name, response_url }) .then((message) => { callback(null, slackResp(message) diff --git a/src/radio.js b/src/radio.js new file mode 100644 index 0000000..9240eb9 --- /dev/null +++ b/src/radio.js @@ -0,0 +1,98 @@ +const request = require('request-promise-native'); +const { extractFromUri } = require('./util/parse'); +const { PL_PENDING } = require('./slack-resp'); +const aws = require('./aws'); + + +const API_BASE = 'https://api.spotify.com/v1'; +const ERROR_EXPIRED_TOKEN = 'Spotify User Access token ' + + 'is expired or invalid. ' + + 'Please check the Spotify host machine.'; +const ERROR_INVALID_TRACK_URI = 'Please provide a uri ' + + 'for a valid Spotify track.'; +const TRACK_ENDPOINT = id => + `${API_BASE}/tracks/${id}`; + +let _functionARN; +let _accessToken; + +module.exports = { + + ERROR_INVALID_TRACK_URI, + ERROR_EXPIRED_TOKEN, + + setAccessToken(token) { + _accessToken = token; + }, + + setFunctionARN(functionARN) { + _functionARN = functionARN + }, + + handleStatusCodeError(err) { + if (err.error) { + let message; + switch (err.statusCode) { + case 404: + case 400: + message = ERROR_INVALID_TRACK_URI; + break; + case 401: + message = ERROR_EXPIRED_TOKEN; + break; + default: + message = err.error.error.message; + } + const error = Error(message); + error.statusCode = err.statusCode; + throw error; + } + return Promise.reject(err) + }, + + getTrackInfo(trackId) { + return request + .get({ + uri: TRACK_ENDPOINT(trackId), + headers: { + Authorization: `Bearer ${_accessToken}` + }, + json: true + }) + .then(({ artists, name, popularity }) => ({ + name, + artist: artists + .map(a => a.name) + .join(', '), + artistIds: artists + .map(a => a.id), + popularity, + id: trackId + })) + .catch(this.handleStatusCodeError); + + }, + + createRadioStation(text, responseUrl) { + const trackId = extractFromUri(text, 'track'); + return this + .getTrackInfo(trackId) + .then(trackInfo => + aws.invokeLambda({ + FunctionName: _functionARN, + InvocationType: 'Event', + LogType: 'Tail', + Payload: JSON.stringify({ + body: { + track:trackInfo, + response_url: responseUrl + } + }) + }).then(() => PL_PENDING(trackInfo)) + ) + .catch(({ message }) => Promise.reject({ + message, + statusCode: 200 + })); + } +}; diff --git a/src/slack-resp.js b/src/slack-resp.js index 37dbf73..abd8b97 100644 --- a/src/slack-resp.js +++ b/src/slack-resp.js @@ -34,6 +34,9 @@ const ADDED = (track, position) => // eslint-disable-next-line babel/new-cap `${TRACK(track)} at position ${position}.`; +const PL_PENDING = ({ name, artist }) => + `Finding tracks based on: "${name}" by ${artist} ...`; + const NOW_PLAYING = track => // eslint-disable-next-line babel/new-cap `Now playing ${TRACK(track)}.`; @@ -83,6 +86,7 @@ module.exports = { CMD_NOT_SUPPORTED, SHUFFLING, NOT_SHUFFLING, + PL_PENDING, TRACK, CURRENT_PL, PL_SET, diff --git a/src/util/parse.js b/src/util/parse.js index d10c324..c803dc2 100644 --- a/src/util/parse.js +++ b/src/util/parse.js @@ -1,4 +1,3 @@ - function parseFormString(str = '') { const parts = str.split('&'); const data = {}; @@ -9,6 +8,17 @@ function parseFormString(str = '') { return data; } +function extractFromUri(uri, property) { + const arr = uri.split(':'); + const propIndex = arr.indexOf(property); + if (propIndex === -1) { + return undefined; + } + return arr[propIndex + 1]; +} + + module.exports = { - parseFormString + parseFormString, + extractFromUri }; diff --git a/test/.env b/test/.env index 7299087..0a3cc2b 100644 --- a/test/.env +++ b/test/.env @@ -1,2 +1,4 @@ SLACK_TOKEN=foo; SPOTIFY_LOCAL_URL=http://localhost:5000 +RADIO_LAMBDA=arn:aws:lambda:us-east-1:0000000:function:testInvokeFunctioInvokee +SPOTIFY_USER_ACCESS_TOKEN=foo diff --git a/test/aws/test_getLambdaClient.js b/test/aws/test_getLambdaClient.js new file mode 100644 index 0000000..7975a3b --- /dev/null +++ b/test/aws/test_getLambdaClient.js @@ -0,0 +1,47 @@ +const { beforeEach, afterEach, describe, it } = require('mocha'); +const { expect } = require('chai'); +const aws = require('../../src/aws'); + +const context = describe; + +describe('The aws.getLambdaClient method', () => { + + context('with a config', () => { + + afterEach(() => { + delete aws.lambdaClient; + }); + + const awsConfig = { + region: 'us-east-1' + }; + + it('creates a lambda client for the module', (done) => { + expect(aws.lambdaClient).to.be.undefined; + const client = aws.getLambdaClient(awsConfig); + expect(client).to.equal(aws.lambdaClient); + expect(aws.lambdaClient).to.not.be.undefined; + done(); + }); + }); + + context('without a config', () => { + + afterEach(() => { + delete aws.lambdaClient; + }); + + it('creates a lambda client for the module', (done) => { + const client = aws.getLambdaClient(); + expect(client).to.equal(aws.lambdaClient); + done(); + }); + + it('creates the lambda client only once ', (done) => { + const client = aws.getLambdaClient(); + const client2 = aws.getLambdaClient(); + expect(client2).to.equal(client); + done(); + }); + }); +}); diff --git a/test/aws/test_invokeLambda.js b/test/aws/test_invokeLambda.js new file mode 100644 index 0000000..69788d6 --- /dev/null +++ b/test/aws/test_invokeLambda.js @@ -0,0 +1,81 @@ +const { beforeEach, afterEach, describe, it } = require('mocha'); +const { expect, config } = require('chai'); +const sinon = require('sinon'); +const aws = require('../../src/aws'); + +const context = describe; + +describe('The aws.invokeLambda method', () => { + context('when successful', () => { + + const successResp = { + StatusCode: 202, + Payload: '' + }; + + beforeEach(() => { + aws.getLambdaClient({ + region: 'us-east-1' + }); + sinon.stub(aws.lambdaClient, 'invoke', (params, cb) => { + cb(null, successResp); + }); + }); + + afterEach(() => { + aws.lambdaClient.invoke.restore(); + delete aws.lambdaClient; + }); + + it('resolves a promise with response data', (done) => { + const params = { + FunctionName: 'arn:aws:lambda:us-east-1:0000000:function:testInvokeFunctioInvokee', + InvocationType: 'Event', + LogType: 'Tail' + }; + aws.invokeLambda(params) + .then((resp) => { + expect(resp).to.eq(successResp); + done(); + }) + .catch(done); + }); + }); + + context('when unsuccessful', () => { + + const expectedError = new Error('Something happened.'); + expectedError.code = 403; + + beforeEach(() => { + aws.getLambdaClient({ + region: 'us-east-1' + }); + sinon.stub(aws.lambdaClient, 'invoke', (params, cb) => { + cb(expectedError); + }); + }); + + afterEach(() => { + aws.lambdaClient.invoke.restore(); + delete aws.lambdaClient; + }); + + it('resolves a promise with response data', (done) => { + const params = { + FunctionName: 'arn:aws:lambda:us-east-1:000000000000:function:testInvokeFunctioInvokee', + InvocationType: 'Event', + LogType: 'Tail' + }; + aws.invokeLambda(params) + .then(() => { + done(Error('This promise should not resolve.')); + }) + .catch((err) => { + expect(err).to.equal(expectedError); + done(); + }) + .catch(done); + }); + }); +}); diff --git a/test/radio/test_createRadioStation.js b/test/radio/test_createRadioStation.js new file mode 100644 index 0000000..8e6f1b6 --- /dev/null +++ b/test/radio/test_createRadioStation.js @@ -0,0 +1,142 @@ +const { beforeEach, afterEach, describe, it } = require('mocha'); +const { expect, config } = require('chai'); +const request = require('request-promise-native'); +const sinon = require('sinon'); +const { PL_PENDING } = require('../../src/slack-resp'); +const radio = require('../../src/radio'); +const aws = require('../../src/aws'); + +const context = describe; + +describe('#The radio.createStation method', () => { + + context('With a valid access token and valid track uri', () => { + + const trackName = 'Skin Tight'; + const artist = 'Ohio Players'; + const text = 'spotify:track:5zOzLQX0cfWFVmMevcvYBD'; + const response_url = 'http://foo.bar.com'; + + beforeEach(() => { + + sinon + .stub(radio, 'getTrackInfo') + .resolves({ + artist, + name: trackName + }); + + sinon + .stub(aws, 'invokeLambda') + .resolves({}) + }); + + afterEach(() => { + radio.getTrackInfo.restore(); + aws.invokeLambda.restore(); + }); + + it('resolves with a message for slack', (done) => { + radio.createRadioStation(text, response_url) + .then(message => { + expect(message) + .to.eq(PL_PENDING({ name: trackName, artist})); + done(); + }) + .catch(done); + }); + }); + + context('With a valid access token but invalid track uri', () => { + beforeEach(() => { + sinon + .stub(radio, 'getTrackInfo') + .rejects({ + message: radio.ERROR_INVALID_TRACK_URI + }) + }); + + afterEach(() => { + radio.getTrackInfo.restore(); + }); + + it('Rejects with a message for slack', (done) => { + radio.createRadioStation('') + .then(() => { + done(Error('Promise should be rejected.')); + }) + .catch(({ message }) => { + expect(message) + .to.eq(radio.ERROR_INVALID_TRACK_URI); + done(); + }) + .catch(done); + }); + }); + + context('With an invalid access token', () => { + beforeEach(() => { + sinon + .stub(radio, 'getTrackInfo') + .rejects({ + message: radio.ERROR_EXPIRED_TOKEN + }) + }); + + afterEach(() => { + radio.getTrackInfo.restore(); + }); + + it('Rejects with a message for slack', (done) => { + radio.createRadioStation('') + .then(() => { + done(Error('Promise should be rejected.')); + }) + .catch(({ message }) => { + expect(message) + .to.eq(radio.ERROR_EXPIRED_TOKEN); + done(); + }) + .catch(done); + }); + }); + + context('When an error occurs invoking the lambda', () => { + beforeEach(() => { + + const trackName = 'Skin Tight'; + const artist = 'Ohio Players'; + + sinon + .stub(radio, 'getTrackInfo') + .resolves({ + artist, + name: trackName + }); + + sinon + .stub(aws, 'invokeLambda') + .rejects(new Error('AWS is busted.')) + + }); + + afterEach(() => { + radio.getTrackInfo.restore(); + aws.invokeLambda.restore(); + }); + + it('sends the error message to slack', (done) => { + radio + .createRadioStation('') + .then(() => { + done(Error('Promise should be rejected.')); + }) + .catch(({ message }) => { + expect(message) + .to.eq('AWS is busted.'); + done(); + }) + .catch(done); + }); + }); +}); diff --git a/test/radio/test_getTrackInfo.js b/test/radio/test_getTrackInfo.js new file mode 100644 index 0000000..7644a00 --- /dev/null +++ b/test/radio/test_getTrackInfo.js @@ -0,0 +1,320 @@ +const { beforeEach, afterEach, describe, it } = require('mocha'); +const { expect, config } = require('chai'); +const request = require('request-promise-native'); +const sinon = require('sinon'); +const radio = require('../../src/radio'); + +const context = describe; + +describe('The radio.getTrackInfo method', () => { + + context('With a valid access token', () => { + + const token = 'valid_token'; + + context('With a valid track id', () => { + + const trackId = '6uVE8zYCeQBkfWOSpcGKMM'; + + beforeEach(() => { + sinon + .stub(request, 'get') + .resolves({ + name: 'Thoughts On Outer Space', + popularity: 21, + artists: [ + { + id: '4ny5u89tQVgw6OmFkj454M', + name: 'Dick Gregory', + } + ] + }) + }); + + afterEach(() => { + request.get.restore(); + }); + + it('resolves a promise with track info', (done) => { + radio + .getTrackInfo(trackId) + .then((trackInfo) => { + expect(trackInfo).to.be.an('object'); + expect(trackInfo).to.not.be.empty; + done(); + }) + .catch(done); + }); + + it('resolves a promise with the track name', (done) => { + radio + .getTrackInfo(trackId) + .then(({ name }) => { + expect(name).to.equal('Thoughts On Outer Space'); + done(); + }) + .catch(done); + }); + + it('resolves a promise with the artist name', (done) => { + radio + .getTrackInfo(trackId) + .then(({ artist }) => { + expect(artist).to.equal('Dick Gregory'); + done(); + }) + .catch(done); + }); + + it('resolves a promise with an array of artist ids', (done) => { + radio + .getTrackInfo(trackId) + .then(({ artistIds }) => { + expect(artistIds).to.be.an('array'); + expect(artistIds).to.have.lengthOf(1); + expect(artistIds).to.contain('4ny5u89tQVgw6OmFkj454M'); + done(); + }) + .catch(done); + }); + + it('resolves a promise with the track popularity ranking', (done) => { + radio + .getTrackInfo(trackId) + .then(({ popularity }) => { + expect(popularity).to.equal(21); + done(); + }) + .catch(done); + }); + + }); + + context('With an invalid track id', () => { + + const trackId = 'bloop'; + + beforeEach(() => { + sinon + .stub(request, 'get') + .rejects({ + statusCode: 400, + error: { + error: { + message: 'Bad track id or something.' + } + } + }) + }); + + afterEach(() => { + request.get.restore(); + }); + + it('rejects a promise with an error message', (done) => { + radio + .getTrackInfo(trackId) + .then(() => { + done(Error('This promise should be rejected.')) + }) + .catch(({ message }) => { + expect(message).to.equal(radio.ERROR_INVALID_TRACK_URI); + done(); + }) + .catch(done); + }); + + it('rejects a promise with an statusCode 400', (done) => { + radio + .getTrackInfo(trackId) + .then(() => { + done(Error('This promise should be rejected.')) + }) + .catch(({ statusCode }) => { + expect(statusCode).to.equal(400); + done(); + }) + .catch(done); + }); + }); + + context('With an track id not found', () => { + + const trackId = '4ny5u89tQVgw6OmFkjxxxx'; + + beforeEach(() => { + sinon + .stub(request, 'get') + .rejects({ + statusCode: 404, + error: { + error: { + message: 'Bad track id or something.' + } + } + }) + }); + + afterEach(() => { + request.get.restore(); + }); + + it('rejects a promise with an error message', (done) => { + radio + .getTrackInfo(trackId) + .then(() => { + done(Error('This promise should be rejected.')) + }) + .catch(({ message }) => { + expect(message).to.equal(radio.ERROR_INVALID_TRACK_URI); + done(); + }) + .catch(done); + }); + + it('rejects a promise with an statusCode 404', (done) => { + radio + .getTrackInfo(trackId) + .then(() => { + done(Error('This promise should be rejected.')) + }) + .catch(({ statusCode }) => { + expect(statusCode).to.equal(404); + done(); + }) + .catch(done); + }); + + }); + }); + + context('With an invalid access token', () => { + const token = 'foo-bar-baz'; + const trackId = '6uVE8zYCeQBkfWOSpcGKMM'; + + beforeEach(() => { + sinon + .stub(request, 'get') + .rejects({ + statusCode: 401, + error: { + error: { + message: 'Bad token or something.' + } + } + }) + }); + + afterEach(() => { + request.get.restore(); + }); + + it('rejects a promise with an error message', (done) => { + radio + .getTrackInfo(trackId) + .then(() => { + done(Error('This promise should be rejected.')) + }) + .catch(({ message }) => { + expect(message).to.equal(radio.ERROR_EXPIRED_TOKEN); + done(); + }) + .catch(done); + }); + + it('rejects a promise with an statusCode 401', (done) => { + radio + .getTrackInfo(trackId) + .then(() => { + done(Error('This promise should be rejected.')) + }) + .catch(({ statusCode }) => { + expect(statusCode).to.equal(401); + done(); + }) + .catch(done); + }); + }); + + context('With some other spotify type of spotify error', () => { + const token = 'foo-bar-baz'; + const trackId = '6uVE8zYCeQBkfWOSpcGKMM'; + const apiErrorMessage = 'Something is wrong with Spotify.'; + + beforeEach(() => { + sinon + .stub(request, 'get') + .rejects({ + statusCode: 503, + error: { + error: { + message: apiErrorMessage + } + } + }) + }); + + afterEach(() => { + request.get.restore(); + }); + + it('rejects a promise with the api error message', (done) => { + radio + .getTrackInfo(trackId) + .then(() => { + done(Error('This promise should be rejected.')); + }) + .catch(({ message }) => { + expect(message).to.equal(apiErrorMessage); + done(); + }) + .catch(done); + }); + + it('rejects a promise with the api statusCode', (done) => { + radio + .getTrackInfo(trackId) + .then(() => { + done(Error('This promise should be rejected.')); + }) + .catch(({ statusCode }) => { + expect(statusCode).to.equal(503); + done(); + }) + .catch(done); + }); + }); + + context('With some other error', () => { + + const token = 'foo-bar-baz'; + const trackId = '6uVE8zYCeQBkfWOSpcGKMM'; + const internalErrorMessage = 'Somethings wrong with the code.'; + const internalError = new Error(internalErrorMessage); + + beforeEach(() => { + + sinon + .stub(request, 'get') + .rejects(internalError); + }); + + afterEach(() => { + request.get.restore(); + }); + + it('rejects a promise with the error', (done) => { + radio + .getTrackInfo(trackId) + .then(() => { + done(Error('This promise should be rejected.')); + }) + .catch((error) => { + expect(error).to.equal(internalError); + done(); + }) + .catch(done); + }); + }); + +}); diff --git a/test/test_commands.js b/test/test_commands.js index 1b2abd9..c2990bb 100644 --- a/test/test_commands.js +++ b/test/test_commands.js @@ -7,6 +7,7 @@ const sinon = require('sinon'); const dotenv = require('dotenv'); const { exec, setAPIRoot } = require('../src/commands'); +const radio = require('../src/radio'); const { ADDED, NONE_QUEUED, @@ -22,7 +23,8 @@ const { VOLUME, VOLUME_SET, INVALID_NUMBER, - SOMEONE_ELSE_IS_TALKING + SOMEONE_ELSE_IS_TALKING, + PL_PENDING } = require('../src/slack-resp'); const context = describe; @@ -637,6 +639,35 @@ describe('The Slack commands for Spotify Local ', () => { }); }); + describe('The /radio command', () => { + + const command = '/radio'; + const text = 'spotify:track:7bJ9wwhHylDAcarYbUzX9Q'; + const user_name = 'chris'; + const response_url = 'http://foo.bar.com'; + const expectedMessage = PL_PENDING({ + name: 'Spaceship Orion', + artist: 'The Ozark Mountain Daredevils' + }) + + beforeEach(() => { + sinon.stub(radio, 'createRadioStation') + .resolves(expectedMessage) + }); + afterEach(() => { + radio.createRadioStation.restore(); + }); + + it('resolves with a command for slack', (done) => { + exec({ command, user_name, text, response_url }) + .then((resp) => { + expect(resp).to.eq(expectedMessage); + done(); + }) + .catch(done); + }) + }); + describe('The /dequeue command', () => { const command = '/dequeue'; const user_name = 'david'; @@ -751,7 +782,5 @@ describe('The Slack commands for Spotify Local ', () => { }); }); }); - - }); }); diff --git a/test/util/test_extractFromUri.js b/test/util/test_extractFromUri.js new file mode 100644 index 0000000..ff1142d --- /dev/null +++ b/test/util/test_extractFromUri.js @@ -0,0 +1,41 @@ +const {describe, it} = require('mocha'); +const { expect, config } = require('chai'); +const { extractFromUri } = require('../../src/util/parse'); + +const context = describe; + + +describe('#util.parse.extractFromUri', () => { + context('With a link to a playlist', () => { + + const uri = 'spotify:user:awpoops:playlist:5PP1I2m0uxEBb3VKLhI7bP'; + + it('can extract the playlist id', () => { + const playlistId = extractFromUri(uri, 'playlist'); + expect(playlistId).to.eq('5PP1I2m0uxEBb3VKLhI7bP'); + }); + + it('can extract the user id', () => { + const userId = extractFromUri(uri, 'user'); + expect(userId).to.eq('awpoops'); + }); + }); + + context('with an invalid string', () => { + const uri = 'foo:bar:baz'; + + it('returns an undefined for playlist', () => { + const playlistId = extractFromUri(uri, 'playlist'); + expect(playlistId).to.eq(undefined); + }); + }); + + context('with an empty string', () => { + const uri = ''; + + it('returns an undefined for playlist', () => { + const playlistId = extractFromUri(uri, 'playlist'); + expect(playlistId).to.eq(undefined); + }); + }); +});