Skip to content

Instantly share code, notes, and snippets.

@lbrenman
Last active January 15, 2018 21:03
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save lbrenman/2af025df1ff5cfd442ca5678e7206e0d to your computer and use it in GitHub Desktop.
Save lbrenman/2af025df1ff5cfd442ca5678e7206e0d to your computer and use it in GitHub Desktop.
Hosting Your Alexa Skill Service on Arrow, Part 2
var Arrow = require('arrow');
const verifier = require('alexa-verifier');
var sendResponse = function(req, resp, next, str) {
resp.response.status(200);
resp.send({
"version": "1.0",
"response": {
"shouldEndSession": true,
"outputSpeech": {
"type": "PlainText",
"text": str
}
}
});
next();
};
var createHelpCountEntry = function(req) {
var model = Arrow.getModel("helpcount");
model.create({uid: req.body.session.user.userId, count: 1}, function(err, instance){
if(err) {
console.log('error creating helpcount database entry, err = '+err);
}
});
};
var getHelpCount = function(req, resp, next) {
var replyText;
var model = Arrow.getModel("helpcount");
model.query({uid: req.body.session.user.userId}, function(err, data){
if(err) {
replyText = "Welcome to Hello Arrow Helper. Ask Hello Arrow to say hi";
} else {
if(data.length == 0) {
replyText = "Welcome to Hello Arrow Helper. This is the first time you have asked for help. Ask Hello Arrow to say hi";
createHelpCountEntry(req);
} else if(data.length > 1) {
replyText = "Welcome to Hello Arrow Helper. Ask Hello Arrow to say hi";
} else {
replyText = "Welcome to Hello Arrow Helper. You have asked for help "+data[0].count+" times. Ask Hello Arrow to say hi";
data[0].count = data[0].count+1;
data[0].update();
}
}
sendResponse(req, resp, next, replyText);
});
};
var alexaskill = function (req, resp, next) {
var reqBody = JSON.stringify(req.body);
switch (req.body.request.type) {
case "LaunchRequest":
// Launch Request
// "Alexa, open Hello Arrow"
sendResponse(req, resp, next, "Welcome to Hello Arrow. Ask Hello Arrow to say hi");
break;
case "IntentRequest":
// Intent Request
switch(req.body.request.intent.name) {
case "HelloArrowIntent":
// "Alexa, ask Hello Arrow to hi"
sendResponse(req, resp, next, "Hello Arrow");
break;
case "AMAZON.HelpIntent":
// "Alexa, ask Hello Arrow for Help"
getHelpCount(req, resp, next);
break;
default:
console.log("Invalid intent");
}
break;
case "SessionEndedRequest":
// Session Ended Request
break;
default:
console.log('INVALID REQUEST TYPE:' +req.body.request.type);
}
};
var AlexaAppHandler = Arrow.API.extend({
group: 'alexa',
path: '/api/alexaapphandler',
method: 'POST',
description: 'this is an api that shows how to handle requests from the Alexa Skill Voice server',
parameters: {
version: {description:'version'},
session: {description:'session'},
context: {description:'context', optional: true},
request: {description:'request'}
},
action: function (req, resp, next) {
console.log('AlexaAppHandler called');
var cert_url = req.headers['signaturecertchainurl'];
var signature = req.headers['signature'];
var requestRawBody = JSON.stringify(req.body);
if(cert_url && signature) {
verifier(cert_url, signature, requestRawBody, function(error){
if(!error) {
alexaskill(req, resp, next);
} else {
// console.log('AlexaAppHandler verify request error = '+error);
resp.response.status(500);
resp.send({"error": "Error verifying source of request to AlexaAppHandler"});
next();
}
});
} else {
// console.log('AlexaAppHandler verify request error. Proper headers not found');
resp.response.status(500);
resp.send({"error": "Proper headers not found"});
next();
}
}
});
module.exports = AlexaAppHandler;

Hosting Your Alexa Skill Service on Arrow, Part 2

In the previous post we discussed how easy it is to host an Alexa Skill Service on Arrow. We used an Arrow Custom API to handle the POST API request from the Alexa Skill Interface and then our custom API created a JSON reply that contained the text that the Amazon Echo spoke back to us.

In this post, we'll continue working on our Custom API and add the following items:

  1. Verify the API request from the Alexa Skill Interface as required for getting certification from Amazon
  2. Implement a few more Skill capabilities to make our skill more useful
  3. Use ArrowDB to store data that will persist between sessions

Verify The API request

According to Amazon we need to verify the request made to our Skill Service to insure that it is coming from Amazon. The steps to do this are:

  1. Check the SignatureCertChainUrl header for validity.
  2. Retrieve the certificate file from the SignatureCertChainUrl header URL.
  3. Check the certificate file for validity (PEM-encoded X.509).
  4. Extract the public key from certificate file.
  5. Decode the encrypted Signature header (it's base64 encoded).
  6. Use the public key to decrypt the signature and retrieve a hash.
  7. Compare the hash in the signature to a SHA-1 hash of entire raw request body.
  8. Check the timestamp of request and reject it if older than 150 seconds.

This must be done on every request.

To make this easier, I leveraged the alexa-verifier npm in my Arrow project.

Note: Remember to add alexa-verifier to the dependency section of package.json:

.
.
"dependencies": {
    "async": "^1.5.0",
    "lodash": "^3.10.1",
    "pkginfo": "^0.3.1",
    "alexa-verifier": "^0.3.0"
},
.
.

My API code is below:

var AlexaAppHandler = Arrow.API.extend({
	group: 'alexa',
	path: '/api/alexaapphandler',
	method: 'POST',
	description: 'this is an api that shows how to handle requests from the Alexa Skill Voice server',
	parameters: {
		version: {description:'version'},
		session: {description:'session'},
		context: {description:'context', optional: true},
		request: {description:'request'}
	},
	action: function (req, resp, next) {
		console.log('AlexaAppHandler called');
		var cert_url = req.headers['signaturecertchainurl'];
		var signature = req.headers['signature'];
		var requestRawBody = JSON.stringify(req.body);
		if(cert_url && signature) {
			verifier(cert_url, signature, requestRawBody, function(error){
				if(!error) {
					alexaskill(req, resp, next);
				} else {
					resp.response.status(500);
					resp.send({"error": "Error verifying source of request to AlexaAppHandler"});
					next();
				}
			});
		} else {
			resp.response.status(500);
			resp.send({"error": "Proper headers not found"});
			next();
		}
	}
});
module.exports = AlexaAppHandler;

In the action preperty, I am extracting the value of two headers: signaturecertchainurl and signature. If they are both present and populated then I pass them to the verifier method which performs the 8 steps outlined above. if the verification passes, then I call a function called alexaskill which I'll cover shortly. Otherwise, I send an error reply to the Alexa Skill Interface and the Echo will say something like "There was a problem with the skill".

Enhancing The Skill Implementation

If the request is verified then the alexaskill function is called. This function implements the skill logic. The skill code below handles the following requests:

  1. LaunchRequest
  2. IntentRequest
  3. SessionEndedRequest

as required and documented here.

var alexaskill = function (req, resp, next) {
	var reqBody = JSON.stringify(req.body);

	switch (req.body.request.type) {
		case "LaunchRequest":
		sendResponse(req, resp, next, "Welcome to Hello Arrow. Ask Hello Arrow to say hi");
		break;

		case "IntentRequest":
			switch(req.body.request.intent.name) {
				case "HelloArrowIntent":
					sendResponse(req, resp, next, "Hello Arrow");
					break;

				case "AMAZON.HelpIntent":
					getHelpCount(req, resp, next);
					break;

				default:
					console.log("Invalid intent");
			}
		break;

		case "SessionEndedRequest":
			// Session Ended Request
			break;

		default:
			console.log('INVALID REQUEST TYPE:' +req.body.request.type);
	}
};

The function above basically handles the 3 request types: LaunchRequest, IntentRequest and SessionEndedRequest and if the request is an IntentRequest it then checks to see which Intent it is: HelloArrowIntent or the built-in AMAZON.HelpIntent.

LaunchRequest

The request LaunchRequest will be sent when the user says: "Alexa, open Hello Arrow". This is your opportunity to tell the user a bit about how to use the skill. Specifically, I tell Alexa to respond with:

"Welcome to Hello Arrow. Ask Hello Arrow to say hi".

HelloArrowIntent

The intent request, HelloArrowIntent, will be sent when the user says: "Alexa, ask Hello Arrow to say hi" or any of the utterances defined in the Skill Interface Interaction model. I tell Alexa to respond with:

"Hello Arrow"

AMAZON.HelpIntent

The intent request, AMAZON.HelpIntent, will be sent when the user says: "Alexa, ask Hello Arrow for help". This is your opportunity to provide help. You can see in the code above, that I call the getHelpCount function.

More on this shortly as it also brings us to the 3rd main topic of this blog post, data persistence.

SessionEndedRequest

The request SessionEndedRequest will be sent when the user says: "exit". This is your opportunity to do any cleanup you may need to do. You cannot send back a response to a SessionEndedRequest.

ArrowDB and Data Persistence

Consider a skill that let's a user create and manage a todo list. Or consider a skill that has a long back and forth interaction with the user such as a recipe skill where the skill can time out and end the session (and have to start over) if the user does not interact within 16 seconds. For these examples and many more, it is important to be able to save data in between sessions.

Amazon promotes the use of DynamoDB for this but since we are running on Arrow, we have access to ArrowDB. With ArrowDB we can define a model that will store a userId (passed in with each request) and any data for the user that we would like to persist.

Recall what an Alexa typical request body looks like below. Note that you can access the userId from the session.user object.

{
	"version": "1.0",
	"session": {
		"new": true,
		"sessionId": "amzn1.echo-api.session.xxxxxx",
		"application": {
			"applicationId": "amzn1.ask.skill.yyyyyyyyy"
		},
		"user": {
			"userId": "amzn1.ask.account.ABCDE"
		}
	},
	"context": {
		"AudioPlayer": {
			"playerActivity": "IDLE"
		},
		"System": {
			"application": {
				"applicationId": "amzn1.ask.skill.yyyyyyyyy"
			},
			"user": {
				"userId": "amzn1.ask.account.ABCDE"
			},
			"device": {
				"supportedInterfaces": {
					"AudioPlayer": {}
				}
			}
		}
	},
	"request": {
		"type": "IntentRequest",
		"requestId": "amzn1.echo-api.request.c04a5519-ea62-453b-80a2-8e6d38945750",
		"timestamp": "2017-02-11T17:29:14Z",
		"locale": "en-US",
		"intent": {
			"name": "HelloArrowIntent"
		}
	}
}

In my skill, I keep track of how many times a user asked for help and tell them the count in the response. For example:

"Welcome to Hello Arrow Helper. You have asked for help 10 times. Ask Hello Arrow to say hi"

The code shown above for handling the AMAZON.HelpIntent intent will call the getHelpCount function. Let's take a look at this function below:

var getHelpCount = function(req, resp, next) {
	var replyText;
	var model = Arrow.getModel("helpcount");
	model.query({uid: req.body.session.user.userId}, function(err, data){
		if(err) {
  			replyText = "Welcome to Hello Arrow Helper. Ask Hello Arrow to say hi";
  		} else {
  			if(data.length == 0) {
				replyText = "Welcome to Hello Arrow Helper. This is the first time you have asked for help. Ask Hello Arrow to say hi";
				createHelpCountEntry(req);
			} else if(data.length > 1) {
				replyText = "Welcome to Hello Arrow Helper. Ask Hello Arrow to say hi";
			} else {
				replyText = "Welcome to Hello Arrow Helper. You have asked for help "+data[0].count+" times. Ask Hello Arrow to say hi";
				data[0].count = data[0].count+1;
				data[0].update();
			}
  		}
  		sendResponse(req, resp, next, replyText);
	});
};

Let's break down the getHelpCount function:

  1. The variable model is a handle to the helpcount ArrowDB database
  • Refer to this link for programmatic CRUD access to Arrow models
  1. Perform a query on the model to find the entry associated with the user (user.userId)
  2. Increment the count and store it back in the database
  3. The rest of the code deals with the following edge cases:
  • first time the user asks for help (i.e. no entry in the database)
  • errors accessing the database
  • multiple entries for the user in the database

If no entry is found for the user, the createHelpCountEntry function is called to create an entry with a count of 1. The code for createHelpCountEntry is shown below:

var createHelpCountEntry = function(req) {
	var model = Arrow.getModel("helpcount");
	model.create({uid: req.body.session.user.userId, count: 1}, function(err, instance){
		if(err) {
  			console.log('error creating helpcount database entry, err = '+err);
  		}
	});
};

The model helpcount is an ArrowDB model defined as follows:

var Arrow = require('arrow');
var Model = Arrow.createModel('helpcount', {
	fields: {
		uid: {
			type: String
		},
		count: {
			type: Number
		}
	},
	connector: 'appc.arrowdb',
	actions: [
		'create',
		'read',
		'update',
		'delete',
		'deleteAll'
	]
});
module.exports = Model;

A recording of the interaction can be found here.

As you can see in this, and the prior, blog post, Arrow provides the means for easily hosting Alexa Skill Services. Furthermore, ArrowDB makes it very simple and straightforward to store persistent data that can be used between sessions.

The custom arrow API can be found here.

@danieljlevine
Copy link

Hey, I'm trying to reproduce your results here. I was able to get part 1 of your Alexa blog working. However, I believe this is failing on the call to verifier(). I tried changing the alexa-verifier entry in package.json to:
"alexa-verifier": "1.0.0" as it looks like it might be the current version of alexa-verifier on Github.

The issue could be that I don't understand how to get the alexa-verifier module incorporated into my API builder project. I'm new to API builder and how things work in node.js like this. I get that verifier = require("alexa-verifier"); line enables your code to call the alexa-verifier module. And that the entry in package.json may do some magic when uploaded to API Builder which perhaps asks node.js to go and get a version of alexa-verifier from the web (somehow, possibly like the way npm would). Or is there a missing step that I need to do that actually inserts the alexa-verifier code into my API Builder project in Appcelelrator Studio before packaging it up?

@yozef
Copy link

yozef commented Jan 8, 2018

I've managed to get it to work and able to publish it to Amazon.

Note: Testing the headers (alexa-verifier) should be done from an Alexa Device (the web console will not work, and will always return "invalid signature").

Update to the code above:
When publishing for certification, Amazon does automated testing on the headers, and expects 400 as http status code to be returned if verification fails & 200 if verification is successful.

        var cert_url = req.headers['signaturecertchainurl'];
        var signature = req.headers['signature'];
        var requestRawBody = JSON.stringify(req.body);
        if(cert_url && signature) {
            verifier(cert_url, signature, requestRawBody, function(error) {
                if(!error) {
                    resp.response.status(200);   // expecting 200 for successful verification
                    alexaskill(req, resp, next);
                } else {
                    resp.response.status(400);    // must be 400
                    resp.send({"Error": "Error verifying source of request to AlexaAppHandler - " + error});
                    next();
                }
            });
        } else {
            resp.response.status(400);   // must be 400
            resp.send({"error": "Proper headers not found"});
            next();
        }

@danieljlevine
Copy link

Thanks, @yozef.

I'll make the changes and try with a real device and see if there's a difference. That's a shame good headers aren't sent by the console.

@danieljlevine
Copy link

Yes, it looks like what you said above is exactly the case. I will note that although it doesn't work from the Alexa Developer console, it does work from Test Simulator (Beta). So that's nice to have for testing. ;-)

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment