diff --git a/src/index.js b/src/index.js index 2ae834d..fa1f8b7 100755 --- a/src/index.js +++ b/src/index.js @@ -1,14 +1,72 @@ -const { response } = require('./util/lambda') +const { parseFormString } = require('./util/parse'); +const slack = require('./slack'); +const { + convertLinkToUri, + extractFromUri +} = require('./spotify'); +const radio = require('./spotify/radio'); +const track = require('./spotify/track'); + function handler(event, context, callback) { - const { body } = event; - const { SPOTIFY_CLIENT_ID, SPOTIFY_SECRET } = process.env; + const { body = '' } = event; + const { + SPOTIFY_USER_ACCESS_TOKEN, + SPOTIFY_LOCAL_URL, + SLACK_TOKEN, + SPOTIFY_RADIO_PLAYLIST + } = process.env; + + const { + text, + token, + response_url + } = parseFormString(body); + + if (token !== SLACK_TOKEN) { + callback(null, + slack.slackResp( + slack.INVALID_TOKEN, + 401, + slack.TYPE_PRIVATE + ) + ); + } else { + + const trackUri = convertLinkToUri(text); + track + .getTrackInfo( + extractFromUri(trackUri, 'track'), + SPOTIFY_USER_ACCESS_TOKEN + ) + .catch((error) => { + callback(null, + slack.slackResp(error.message) + ); + }) + .then((trackInfo) => { + callback(null, + slack.slackResp(radio.SLACK_PENDING_MESSAGE(trackInfo)) + ); + radio + .playBasedOnTrack( + SPOTIFY_RADIO_PLAYLIST, + trackInfo, + SPOTIFY_USER_ACCESS_TOKEN, + SPOTIFY_LOCAL_URL + ) + .then((msg) => { + console.log('notify', response_url, msg); + + slack.notify(response_url, msg); + }) + .catch(({ message }) => { + slack.notify(response_url, `Error creating playlist: ${message}`) + }); + }); - callback(null, response({ - message: 'It works!', - body: body - })); + } } module.exports = { diff --git a/src/slack/index.js b/src/slack/index.js new file mode 100644 index 0000000..388067d --- /dev/null +++ b/src/slack/index.js @@ -0,0 +1,37 @@ +const request = require('request-promise-native'); +const { response } = require('../util/lambda') + +// response types +const TYPE_PRIVATE = 'ephemeral'; +const TYPE_PUBLIC = 'in_channel'; + +// error messages +const INVALID_TOKEN = 'Slack token is invalid.'; + +function slackResp(text, code = 200, type = TYPE_PUBLIC) { + return response({ + // eslint-disable-next-line camelcase + response_type: type, + text + }, code); +} + +module.exports = { + TYPE_PUBLIC, + TYPE_PRIVATE, + INVALID_TOKEN, + slackResp, + notify(uri, text) { + return request + .post({ + uri, + headers: { + 'content-type': 'application/json' + }, + body: { + text + }, + json: true + }) + } +}; diff --git a/src/spotify/auth.js b/src/spotify/auth.js index 929f52e..44dc2aa 100644 --- a/src/spotify/auth.js +++ b/src/spotify/auth.js @@ -1,6 +1,4 @@ const request = require('request-promise-native'); -const { API_BASE } = require('../spotify'); - const TOKEN_ERROR = 'Error getting Spotify Token'; const TOKEN_ENDPOINT = 'https://accounts.spotify.com/api/token'; diff --git a/src/spotify/index.js b/src/spotify/index.js index 9d4f0e7..90d15cd 100644 --- a/src/spotify/index.js +++ b/src/spotify/index.js @@ -1,8 +1,20 @@ -const API_BASE = 'https://api.spotify.com/'; +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.'; module.exports = { API_BASE, - + ERROR_EXPIRED_TOKEN, + ERROR_INVALID_TRACK_URI, + convertLinkToUri(link) { + return link + .replace('https://open.spotify.com', 'spotify') + .split('/').join(':'); + }, extractFromUri(uri, property) { const arr = uri.split(':'); const propIndex = arr.indexOf(property); diff --git a/src/spotify/playlist.js b/src/spotify/playlist.js new file mode 100644 index 0000000..2d4450e --- /dev/null +++ b/src/spotify/playlist.js @@ -0,0 +1,53 @@ +const request = require('request-promise-native'); +const { + API_BASE, + extractFromUri +} = require('../spotify'); + +const PLAYLIST_ENDPOINT = (user, id) => + `${API_BASE}/users/${user}/playlists/${id}`; + +const PLAYLIST_TRACKS_ENDPOINT = (user, id) => + `${PLAYLIST_ENDPOINT(user, id)}/tracks`; + + +module.exports = { + + renamePlaylist(playlist, name, accessToken) { + + const userId = extractFromUri(playlist, 'user'); + const playlistId = extractFromUri(playlist, 'playlist'); + + return request + .put({ + json: true, + uri: PLAYLIST_ENDPOINT(userId, playlistId), + headers: { + Authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json' + }, + body: { + name + } + }); + }, + + populatePlaylist(playlist, uris, accessToken) { + + const userId = extractFromUri(playlist, 'user'); + const playlistId = extractFromUri(playlist, 'playlist'); + + return request + .put({ + json: true, + uri: PLAYLIST_TRACKS_ENDPOINT(userId, playlistId), + headers: { + Authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json' + }, + body: { + uris + } + }); + } +}; diff --git a/src/spotify/radio.js b/src/spotify/radio.js new file mode 100644 index 0000000..34e0acb --- /dev/null +++ b/src/spotify/radio.js @@ -0,0 +1,104 @@ +const request = require('request-promise-native'); +const { + API_BASE +} = require('../spotify'); +const playlist = require('../spotify/playlist'); +const track = require('../spotify/track'); + + +// Response templates +const PLAYLIST_NAME = basedOn => + `Radio: based on ${basedOn}`; +const TRACK_NAME = (artist, songName) => + `"${songName}" by ${artist}`; + +const RECCOMENDED_TRACKS_ENDPOINT = `${API_BASE}/recommendations`; +const SLACK_SUCCESS_MESSAGE = playlistName => + `Playlist changed to ${playlistName}`; + +const SLACK_PENDING_MESSAGE = ({ name, artist }) => + `Finding tracks based on: ${TRACK_NAME(artist, name)}...`; + +module.exports = { + + PLAYLIST_NAME, + TRACK_NAME, + SLACK_SUCCESS_MESSAGE, + SLACK_PENDING_MESSAGE, + + getRecommendationsFromTrack(trackId, trackInfo, accessToken) { + + const { energy, valence, popularity } = trackInfo; + + return request + .get({ + uri: RECCOMENDED_TRACKS_ENDPOINT, + headers: { + Authorization: `Bearer ${accessToken}` + }, + json: true, + qs: { + limit: 100, + seed_tracks: trackId, + seed_artists: trackInfo.artistIds.join(','), + max_popularity: popularity, + target_energy: energy, + target_valence: valence, + market:'US' + } + }) + .then(({ tracks }) => tracks.map(({ uri }) => uri)); + }, + + createStation(playlistUri, trackInfo, accessToken) { + + const trackId = trackInfo.id; + const playlistName = PLAYLIST_NAME( + TRACK_NAME(trackInfo.artist, trackInfo.name) + ); + + return track + .getTrackFeatures(trackId, accessToken) + .then(features => + this.getRecommendationsFromTrack( + trackId, + Object.assign(features, trackInfo), + accessToken + ) + ) + .then(uris => + playlist.populatePlaylist(playlistUri, uris, accessToken) + ) + .then(() => + playlist.renamePlaylist(playlistUri, playlistName, accessToken) + ) + .then(() => playlistName) + .catch((err) => { + if (err.error) { + const error = Error(err.error.error.message); + error.statusCode = err.statusCode; + throw error; + } + return Promise.reject(err) + }); + }, + + playBasedOnTrack(playlistUri, trackUri, accessToken, spotifyLocalUrl) { + + return this + .createStation(playlistUri, trackUri, accessToken) + .then(() => + request + .post({ + uri: `${spotifyLocalUrl}/api/spotify/playlist`, + body: { + playlist: playlistUri + }, + json: true + }) + ) + .then(resp => + SLACK_SUCCESS_MESSAGE(resp.playlist.title) + ); + } +}; diff --git a/src/spotify/track.js b/src/spotify/track.js new file mode 100644 index 0000000..f30d8e1 --- /dev/null +++ b/src/spotify/track.js @@ -0,0 +1,73 @@ +const request = require('request-promise-native'); +const { + API_BASE, + ERROR_EXPIRED_TOKEN, + ERROR_INVALID_TRACK_URI, + extractFromUri +} = require('../spotify'); + +// Endpoint templates +const TRACK_ENDPOINT = id => + `${API_BASE}/tracks/${id}`; + +const TRACK_FEATURES_ENDPOINT = trackId => + `${API_BASE}/audio-features/${trackId}`; + +module.exports = { + + 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, accessToken) { + 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 => extractFromUri(a.uri, 'artist')), + popularity, + id: trackId + })) + .catch(this.handleStatusCodeError); + + }, + + getTrackFeatures(trackId, accessToken) { + return request + .get({ + uri: TRACK_FEATURES_ENDPOINT(trackId), + headers: { + Authorization: `Bearer ${accessToken}` + }, + json: true + }) + .catch(this.handleStatusCodeError); + }, +}; diff --git a/test/.test-env b/test/.test-env new file mode 100644 index 0000000..099cba7 --- /dev/null +++ b/test/.test-env @@ -0,0 +1,5 @@ +SLACK_TOKEN=foo_bar_baz +EXPIRED_SPOTIFY_ACCESS_TOKEN=BQANyQM3QT080jey_TnbnVSvF1Xr9lKrEc31YkiOlENYwKDuv-3261TtzAWoB0v80IXIPuscAOz7lfTXyfr6WHqCMbAa4DUhRJicDzW1kZ7UV6ofNGWXwoB8raB8ut7q9STN0yIrkvlLvgEAGd6Wsu6Ibfo1olJUDJ0Q2VCAIaNrVtG852AhMp_Wpjzs-K74CYpxO2AtYYNcXo3s0WA7gao84eh1qTnqhprnSbGIjLShqVsmZTo +SPOTIFY_LOCAL_URL=http://localhost:5000 +SPOTIFY_RADIO_PLAYLIST=spotify:user:awpoops:playlist:5PP1I2m0uxEBb3VKLhI7bP +SPOTIFY_USER_ACCESS_TOKEN=BQDeNBJSIWXe3Ezx3IYKf95NJIWeA82cIOFgnDgcc0IwbblLBpi3qEAXz-0R59yAXZ4s4SYz1phS6fWHSWVkDxo08KfSZCUpA-0xEM9FUom7QII2_DL3Ti293Zrhiz1jL4fC-dGLiRxtr7NjImZntCUcss4fBxgrVjuhwCPP4T3AMOljBkE4vY5VNyWq_Pf4aRF1JshyfNg6KLJiL-xClp_AUMglH60IgsjerXWYFq3pt-b8H_U diff --git a/test/spotify/auth/test_get_token.js b/test/spotify/auth/test_get_token.js index 44cdb1f..31f9053 100644 --- a/test/spotify/auth/test_get_token.js +++ b/test/spotify/auth/test_get_token.js @@ -1,14 +1,11 @@ -const PATH = require('path'); const { beforeEach, afterEach, describe, it } = require('mocha'); const { expect, config } = require('chai'); const request = require('request-promise-native'); const sinon = require('sinon'); -const dotenv = require('dotenv'); const token = require('../../../src/spotify/auth'); const context = describe; - config.includeStack = true; describe('The spotify.token.getToken method', () => { diff --git a/test/spotify/radio/test_createStation.js b/test/spotify/radio/test_createStation.js new file mode 100644 index 0000000..8ec578b --- /dev/null +++ b/test/spotify/radio/test_createStation.js @@ -0,0 +1,222 @@ +const { beforeEach, afterEach, describe, it } = require('mocha'); +const { expect, config } = require('chai'); +const request = require('request-promise-native'); +const sinon = require('sinon'); +const dotenv = require('dotenv'); +const { + ERROR_EXPIRED_TOKEN, + ERROR_INVALID_TRACK_URI, +} = require('../../../src/spotify'); +const track = require('../../../src/spotify/track'); +const playlist = require('../../../src/spotify/playlist'); +const radio = require('../../../src/spotify/radio'); + + +const context = describe; + +config.includeStack = true; + +describe('The spotify.radio.createStation method', () => { + + context('With an expired access token', () => { + + const playlistUri = 'spotify:user:awpoops:playlist:5PP1I2m0uxEBb3VKLhI7bP'; + const token = 'thistokenisexpired'; + const trackUri = 'spotify:track:5RgFlk1fcClZd0Y4SGYhqH'; + + beforeEach(() => { + sinon + .stub(track, 'getTrackFeatures') + .rejects({ + statusCode: 401, + message: ERROR_EXPIRED_TOKEN + }); + }); + + afterEach(() => { + track.getTrackFeatures.restore(); + }); + + it('rejects with a 401 status code', (done) => { + + radio + .createStation( + playlistUri, + trackUri, + token + ) + .then(() => { + done('This promise should be rejected.') + }) + .catch((err) => { + expect(err.statusCode).to.eq(401); + done(); + }) + .catch(done); + }); + + it('rejects with an error message saying the token is expired', (done) => { + radio + .createStation( + playlistUri, + trackUri, + token + ) + .then(() => { + done('This promise should be rejected.') + }) + .catch((err) => { + expect(err.message).to.eq(ERROR_EXPIRED_TOKEN); + done(); + }) + .catch(done); + }); + }); + + context('With a valid access token', () => { + + const token = 'avalidaccesstoken'; + const playlistUri = 'spotify:user:awpoops:playlist:5PP1I2m0uxEBb3VKLhI7bP'; + + context('With a valid spotify track', () => { + + const trackInfo = { + name: 'She\'s Always a Woman', + artist: 'Billy Joel', + artistIds: ['6zFYqv1mOsgBRQbae3JJ9e'], + popularity: 70, + id: '5RgFlk1fcClZd0Y4SGYhqH' + }; + beforeEach(() => { + + sinon + .stub(track, 'getTrackFeatures') + .resolves({ + danceability: 0.292, + energy: 0.324, + key: 3, + loudness: -11.996, + mode: 1, + speechiness: 0.0346, + acousticness: 0.797, + instrumentalness: 0.000473, + liveness: 0.12, + valence: 0.368, + tempo: 176.631, + duration_ms: 201373, + time_signature: 3 + }); + sinon + .stub(radio, 'getRecommendationsFromTrack') + .resolves([ + 'spotify:track:3tWBLzt1QY9A9brUfWWEPO', + 'spotify:track:1MSXGbvydpblJZYyiMdfaa', + 'spotify:track:7wOD54k4zprUibDaa8dYv1', + 'spotify:track:12nhoRghPNDChpsFaQld4a' + ]); + sinon + .stub(playlist, 'populatePlaylist') + .resolves({}) + sinon + .stub(playlist, 'renamePlaylist') + .resolves({}) + }); + + afterEach(() => { + track.getTrackFeatures.restore(); + radio.getRecommendationsFromTrack.restore(); + playlist.populatePlaylist.restore(); + playlist.renamePlaylist.restore(); + + }); + + it('resolves with a message for slack', (done) => { + radio + .createStation( + playlistUri, + trackInfo, + token + ) + .then((message) => { + expect(message).to.eq( + 'Radio: based on ' + + '"She\'s Always a Woman" by Billy Joel' + ); + done(); + }) + .catch(done); + }); + }); + + context('With an invalid spotify track', () => { + + beforeEach(() => { + sinon + .stub(track, 'getTrackFeatures') + .rejects({ + statusCode: 400, + message: ERROR_INVALID_TRACK_URI + }); + }); + + afterEach(() => { + track.getTrackFeatures.restore(); + }); + + const trackUri = 'spotify:foo:bar'; + + it('rejects with a 400 status code', (done) => { + radio + .createStation( + playlistUri, + trackUri, + token + ) + .then(() => { + done('This promise should be rejected.') + }) + .catch((err) => { + expect(err.message).to.eq(ERROR_INVALID_TRACK_URI); + expect(err.statusCode).to.eq(400); + done(); + }) + .catch(done); + }); + }); + + context('With a missing spotify track', () => { + + beforeEach(() => { + sinon + .stub(track, 'getTrackFeatures') + .rejects({ + statusCode: 404, + message: ERROR_INVALID_TRACK_URI + }); + }); + + afterEach(() => { + track.getTrackFeatures.restore(); + }); + + const trackUri = 'spotify:track:xxxxxxxxxxxXXxxxxxx00x'; + it('rejects with a 400 status code', (done) => { + radio + .createStation( + playlistUri, + trackUri, + token + ) + .then(() => { + done('This promise should be rejected.') + }) + .catch((err) => { + expect(err.message).to.eq(ERROR_INVALID_TRACK_URI); + expect(err.statusCode).to.eq(404); + done(); + }) + .catch(done); + }); + }); + }); +}); diff --git a/test/spotify/radio/test_playBasedOnTrack.js b/test/spotify/radio/test_playBasedOnTrack.js new file mode 100644 index 0000000..5417a89 --- /dev/null +++ b/test/spotify/radio/test_playBasedOnTrack.js @@ -0,0 +1,57 @@ +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/spotify/radio'); + + +const context = describe; + +config.includeStack = true; + +describe('The spotify.radio.playBasedOnTrack method', () => { + + context('with a working spotifyLocal connection', () => { + + const playlistName = radio.PLAYLIST_NAME( + '"Everyone" by Van Morrison' + ); + + beforeEach(() => { + sinon + .stub(radio, 'createStation') + .resolves({}) + + sinon + .stub(request, 'post') + .resolves({ + playlist: { + title: playlistName + } + }) + + }); + + afterEach(() => { + radio.createStation.restore(); + request.post.restore(); + }); + + it('resolves with a message for slack', (done) => { + + const trackUri = 'spotify:track:528kEbmXBOuMbxdn7YQAXx'; + const spotifyLocalUrl = 'http://localhost:5000'; + const playlistUri = 'spotify:user:awpoops:playlist:5PP1I2m0uxEBb3VKLhI7bP'; + const token = 'assume_this_is_valid'; + radio + .playBasedOnTrack(playlistUri, trackUri, token, spotifyLocalUrl) + .then((message) => { + expect(message).to.equal( + radio.SLACK_SUCCESS_MESSAGE(playlistName) + ) + done(); + }) + .catch(done); + }) + }); +}); \ No newline at end of file diff --git a/test/spotify/test_convertLinkToUri.js b/test/spotify/test_convertLinkToUri.js new file mode 100644 index 0000000..777045a --- /dev/null +++ b/test/spotify/test_convertLinkToUri.js @@ -0,0 +1,35 @@ +const {describe, it} = require('mocha'); +const { expect, config } = require('chai'); +const { convertLinkToUri } = require('../../src/spotify'); + + +const context = describe; + +config.includeStack = true; + +describe('#spotify.convertLinkToUri', () => { + + context('with an http link to a spotify track', () => { + it('converts it to a uri', () => { + const link = 'https://open.spotify.com/track/5uSXPDwXLpYF7aLmTnB3Mb'; + const uri = convertLinkToUri(link); + expect(uri).to.equal('spotify:track:5uSXPDwXLpYF7aLmTnB3Mb'); + }); + }); + + context('with a uri to a spotify track', () => { + it('returns the uri as is', () => { + const link = 'spotify:track:5uSXPDwXLpYF7aLmTnB3Mb'; + const uri = convertLinkToUri(link); + expect(uri).to.equal('spotify:track:5uSXPDwXLpYF7aLmTnB3Mb'); + }); + }); + + context('with a string that is not a spotify link', () => { + it('doesn\'t break', () => { + const link = 'https://developer.spotify.com/web-api/get-track/'; + const uri = convertLinkToUri(link); + expect(uri).to.be.a('String'); + }); + }); +}); diff --git a/test/spotify/test_extractFromUri.js b/test/spotify/test_extractFromUri.js index 1973448..4d1a5d1 100644 --- a/test/spotify/test_extractFromUri.js +++ b/test/spotify/test_extractFromUri.js @@ -5,7 +5,7 @@ const { extractFromUri } = require('../../src/spotify'); const context = describe; -describe('The extractFromUri util', () => { +describe('#spotify.extractFromUri', () => { context('With a link to a playlist', () => { const uri = 'spotify:user:awpoops:playlist:5PP1I2m0uxEBb3VKLhI7bP'; diff --git a/test/test_index.js b/test/test_index.js index e9d39c7..428b93c 100755 --- a/test/test_index.js +++ b/test/test_index.js @@ -3,20 +3,87 @@ const { beforeEach, afterEach, describe, it } = require('mocha'); const { expect, config } = require('chai'); const request = require('request-promise-native'); const sinon = require('sinon'); - +const dotenv = require('dotenv'); +const { INVALID_TOKEN } = require('../src/slack'); const { handler } = require('../src'); +const slack = require('../src/slack'); +const radio = require('../src/spotify/radio'); +const track = require('../src/spotify/track'); const context = describe; config.includeStack = true; // uncomment to test with credentials from .env -// dotenv.config(); +dotenv.config({ + path: PATH.resolve(__dirname, '../', 'test/.test-env') +}); describe('The Index Lambda Handler', () => { - context('with an request event', () => { - const event = { body: {} }; + context('with a request event without a slack token', () => { + const event = { }; + + it('sends a response body that can be parsed as JSON ', (done) => { + handler(event, {}, (err, resp) => { + try { + const { text } = JSON.parse(resp.body); + expect(text) + .to.eq(INVALID_TOKEN); + done() + } catch (error) { + done(error); + } + }); + }); + + it('sends a responseCode 401', (done) => { + handler(event, {}, (err, resp) => { + try { + expect(resp.statusCode) + .to.eq(401); + done() + } catch (error) { + done(error); + } + }); + }); + }); + + context('with an request event with a valid token', () => { + + const spotifyTrack = 'spotify:track:2771LMNxwf62FTAdpJMQfM'; + const notificationUri = encodeURIComponent('https://hooks.slack.com/commands/T4ZLYGVSN/227562856215/B4XvvRukWrmUzSJ0cMC0arpE'); + const slackBody = `text=${spotifyTrack}&token=foo_bar_baz&response_url=${notificationUri}`; + const event = { body: slackBody }; + const trackInfo = { + name: 'Bodak Yellow', + artist: 'Cardi B', + id: '2771LMNxwf62FTAdpJMQfM' + }; + const respMsg = radio.SLACK_PENDING_MESSAGE(trackInfo); + + beforeEach(() => { + sinon + .stub(radio, 'playBasedOnTrack') + .resolves(respMsg); + + sinon + .stub(track, 'getTrackInfo') + .resolves(trackInfo); + + // todo assert calls + sinon + .stub(slack, 'notify') + .resolves({}); + + }); + + afterEach(() => { + radio.playBasedOnTrack.restore(); + track.getTrackInfo.restore(); + slack.notify.restore(); + }); it('sends a response body', (done) => { handler(event, {}, (err, resp) => { @@ -33,12 +100,13 @@ describe('The Index Lambda Handler', () => { it('sends a response body that can be parsed as JSON ', (done) => { handler(event, {}, (err, resp) => { try { - const { message } = JSON.parse(resp.body); - expect(message) - .to.eq('It works!'); + const { text } = JSON.parse(resp.body); + expect(text) + .to.eq(respMsg); done() } catch (error) { - done(error); + done(error); console.log('Send Slack notification that this worked:', msg, response_url); + } }); });