Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
…ify-lambda

* 'develop' of https://github.com/chrisdevwords/slack-spotify-lambda:
  #19 wiring up the radio command
  #19 createRadioStation method works
  #19 adding playlist pending message to slack response
  #19 setting radio arn and access token direclty on radio module instead of passing from command module
  #19 adding a createRadio station spec
  #19 adding stub test env var for access token, radio lambda arn
  #19 parsing the response_url from the body, passing it to the command, stubbing out a radio command handler
  #19 setting accesToken in the index handler
  #19 adding a getTrackInfo method
  #19 setAccessToken method called from index handler, pulls from env var
  #19 adding a setAccessToken method to slack command module
  #19 adding a getTrackInfo method
  #19 adding extract from uri util
  #19 adding aws module for invoking other lambda functions
  #19 adding the aws sdk to dev dependencies
  • Loading branch information
chrisdevwords authored and chrisdevwords committed Dec 17, 2017
2 parents e2af3a4 + b5de66f commit 29231ea
Show file tree
Hide file tree
Showing 14 changed files with 818 additions and 9 deletions.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions 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);
}
});
});
}
};
7 changes: 5 additions & 2 deletions src/commands.js
@@ -1,5 +1,5 @@
const request = require('request-promise-native');

const radio = require('./radio');
const {
CMD_NOT_SUPPORTED,
NOW_PLAYING,
Expand Down Expand Up @@ -281,7 +281,7 @@ function say(text) {
})
}

function exec({ text, user_name, command }) {
function exec({ text, user_name, command, response_url }) {

let error;

Expand Down Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions 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,
Expand All @@ -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);

Expand All @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions 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
}));
}
};
4 changes: 4 additions & 0 deletions src/slack-resp.js
Expand Up @@ -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)}.`;
Expand Down Expand Up @@ -83,6 +86,7 @@ module.exports = {
CMD_NOT_SUPPORTED,
SHUFFLING,
NOT_SHUFFLING,
PL_PENDING,
TRACK,
CURRENT_PL,
PL_SET,
Expand Down
14 changes: 12 additions & 2 deletions src/util/parse.js
@@ -1,4 +1,3 @@

function parseFormString(str = '') {
const parts = str.split('&');
const data = {};
Expand All @@ -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
};
2 changes: 2 additions & 0 deletions 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
47 changes: 47 additions & 0 deletions 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();
});
});
});
81 changes: 81 additions & 0 deletions 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);
});
});
});

0 comments on commit 29231ea

Please sign in to comment.