Permalink
Find file
641 lines (596 sloc) 20.8 KB
"use strict";
var Promise = require("bluebird");
var AlexaUtterances = require("alexa-utterances");
var SSML = require("./lib/to-ssml");
var alexa = {};
var defaults = require("lodash.defaults");
var verifier = require("alexa-verifier-middleware");
var bodyParser = require('body-parser');
var normalizeApiPath = require('./lib/normalize-api-path');
alexa.response = function(session) {
var self = this;
this.resolved = false;
this.response = {
"version": "1.0",
"response": {
"directives": [],
"shouldEndSession": true
}
};
this.say = function(str) {
if (typeof this.response.response.outputSpeech == "undefined") {
this.response.response.outputSpeech = {
"type": "SSML",
"ssml": SSML.fromStr(str)
};
} else {
// append str to the current outputSpeech, stripping the out speak tag
this.response.response.outputSpeech.ssml = SSML.fromStr(str, this.response.response.outputSpeech.ssml);
}
return this;
};
this.clear = function( /*str*/ ) {
this.response.response.outputSpeech = {
"type": "SSML",
"ssml": SSML.fromStr("")
};
return this;
};
this.reprompt = function(str) {
if (typeof this.response.response.reprompt == "undefined") {
this.response.response.reprompt = {
"outputSpeech": {
"type": "SSML",
"ssml": SSML.fromStr(str)
}
};
} else {
// append str to the current outputSpeech, stripping the out speak tag
this.response.response.reprompt.outputSpeech.ssml = SSML.fromStr(str, this.response.response.reprompt.outputSpeech.text);
}
return this;
};
this.card = function(oCard) {
if (2 == arguments.length) { // backwards compat
oCard = {
type: "Simple",
title: arguments[0],
content: arguments[1]
};
}
var requiredAttrs = [],
clenseAttrs = [];
switch (oCard.type) {
case 'Simple':
requiredAttrs.push('content');
clenseAttrs.push('content');
break;
case 'Standard':
requiredAttrs.push('text');
clenseAttrs.push('text');
if (('image' in oCard) && (!('smallImageUrl' in oCard['image']) && !('largeImageUrl' in oCard['image']))) {
console.error('If card.image is defined, must specify at least smallImageUrl or largeImageUrl');
return this;
}
break;
case 'AskForPermissionsConsent':
requiredAttrs.push('permissions');
break;
default:
break;
}
var hasAllReq = requiredAttrs.every(function(idx) {
if (!(idx in oCard)) {
console.error('Card object is missing required attr "' + idx + '"');
return false;
}
return true;
});
if (!hasAllReq) {
return this;
}
// remove all SSML to keep the card clean
clenseAttrs.forEach(function(idx) {
oCard[idx] = SSML.cleanse(oCard[idx]);
});
this.response.response.card = oCard;
return this;
};
this.linkAccount = function() {
this.response.response.card = {
"type": "LinkAccount"
};
return this;
};
this.shouldEndSession = function(bool, reprompt) {
this.response.response.shouldEndSession = bool;
if (reprompt) {
this.reprompt(reprompt);
}
return this;
};
this.sessionObject = session;
this.setSessionAttributes = function(attributes) {
this.response.sessionAttributes = attributes;
};
// prepare response object
this.prepare = function() {
this.setSessionAttributes(this.sessionObject.getAttributes());
};
this.audioPlayerPlay = function(playBehavior, audioItem) {
var audioPlayerDirective = {
"type": "AudioPlayer.Play",
"playBehavior": playBehavior,
"audioItem": audioItem
};
self.response.response.directives.push(audioPlayerDirective);
return this;
};
this.audioPlayerPlayStream = function(playBehavior, stream) {
var audioItem = {
"stream": stream
};
return this.audioPlayerPlay(playBehavior, audioItem);
};
this.audioPlayerStop = function() {
var audioPlayerDirective = {
"type": "AudioPlayer.Stop"
};
self.response.response.directives.push(audioPlayerDirective);
return this;
};
this.audioPlayerClearQueue = function(clearBehavior) {
var audioPlayerDirective = {
"type": "AudioPlayer.ClearQueue",
"clearBehavior": clearBehavior || "CLEAR_ALL"
};
self.response.response.directives.push(audioPlayerDirective);
return this;
};
// legacy code below
// @deprecated
this.session = function(key, val) {
if (typeof val == "undefined") {
return this.sessionObject.get(key);
} else {
this.sessionObject.set(key, val);
}
return this;
};
// @deprecated
this.clearSession = function(key) {
this.sessionObject.clear(key);
return this;
};
};
alexa.request = function(json) {
this.data = json;
this.slot = function(slotName, defaultValue) {
try {
if (this.data.request.intent.slots && slotName in this.data.request.intent.slots) {
return this.data.request.intent.slots[slotName].value;
} else {
return defaultValue;
}
} catch (e) {
console.error("missing intent in request: " + slotName, e);
return defaultValue;
}
};
this.type = function() {
if (!(this.data && this.data.request && this.data.request.type)) {
console.error("missing request type:", this.data);
return;
}
return this.data.request.type;
};
this.isAudioPlayer = function() {
var requestType = this.type();
return (requestType && 0 === requestType.indexOf("AudioPlayer."));
};
this.isPlaybackController = function() {
var requestType = this.type();
return (requestType && 0 === requestType.indexOf("PlaybackController."));
};
this.userId = null;
this.applicationId = null;
this.context = null;
if (this.data.context) {
this.userId = this.data.context.System.user.userId;
this.applicationId = this.data.context.System.application.applicationId;
this.context = this.data.context;
}
var session = new alexa.session(json.session);
this.hasSession = function() {
return session.isAvailable();
};
this.getSession = function() {
return session;
};
// legacy code below
// @deprecated
this.sessionDetails = this.getSession().details;
// @deprecated
this.sessionId = this.getSession().sessionId;
// @deprecated
this.sessionAttributes = this.getSession().attributes;
// @deprecated
this.isSessionNew = this.hasSession() ? this.getSession().isNew() : false;
// @deprecated
this.session = function(key) {
return this.getSession().get(key);
};
};
alexa.session = function(session) {
var isAvailable = (typeof session != "undefined");
this.isAvailable = function() {
return isAvailable;
};
if (isAvailable) {
this.isNew = function() {
return (true === session.new);
};
this.get = function(key) {
// getAttributes deep clones the attributes object, so updates to objects
// will not affect the session until `set` is called explicitly
return this.getAttributes()[key];
};
this.set = function(key, value) {
this.attributes[key] = value;
};
this.clear = function(key) {
if (typeof key == "string") {
if (typeof this.attributes[key] != "undefined") {
delete this.attributes[key];
}
} else {
this.attributes = {};
}
};
// load the alexa session information into details
this.details = session;
// @deprecated
this.details.userId = this.details.user.userId || null;
// @deprecated
this.details.accessToken = this.details.user.accessToken || null;
// persist all the session attributes across requests
// the Alexa API doesn't think session variables should persist for the entire
// duration of the session, but I do
this.attributes = session.attributes || {};
this.sessionId = session.sessionId;
} else {
this.isNew = this.get = this.set = this.clear = function() {
throw "NO_SESSION";
};
this.details = {};
this.attributes = {};
this.sessionId = null;
}
this.getAttributes = function() {
// deep clone attributes so direct updates to objects are not set in the
// session unless `.set` is called explicitly
return JSON.parse(JSON.stringify(this.attributes));
};
};
alexa.apps = {};
alexa.app = function(name) {
if (!(this instanceof alexa.app)) {
throw new Error("Function must be called with the new keyword");
}
var self = this;
this.name = name;
this.messages = {
// when an intent was passed in that the application was not configured to handle
"NO_INTENT_FOUND": "Sorry, the application didn't know what to do with that intent",
// when an AudioPlayer event was passed in that the application was not configured to handle
"NO_AUDIO_PLAYER_EVENT_HANDLER_FOUND": "Sorry, the application didn't know what to do with that AudioPlayer event",
// when the app was used with 'open' or 'launch' but no launch handler was defined
"NO_LAUNCH_FUNCTION": "Try telling the application what to do instead of opening it",
// when a request type was not recognized
"INVALID_REQUEST_TYPE": "Error: not a valid request",
// when a request and response don't contain session object
// https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/alexa-skills-kit-interface-reference#request-body-parameters
"NO_SESSION": "This request doesn't support session attributes",
// if some other exception happens
"GENERIC_ERROR": "Sorry, the application encountered an error"
};
// persist session variables from every request into every response
this.persistentSession = true;
// use a minimal set of utterances or the full cartesian product
this.exhaustiveUtterances = false;
// a catch-all error handler do nothing by default
this.error = null;
// pre/post hooks to be run on every request
this.pre = function( /*request, response, type*/ ) {};
this.post = function( /*request, response, type*/ ) {};
// a mapping of keywords to arrays of possible values, for expansion of sample utterances
this.dictionary = {};
this.intents = {};
this.intent = function(intentName, schema, func) {
if (typeof schema == "function") {
func = schema;
schema = null;
}
self.intents[intentName] = {
"name": intentName,
"function": func
};
if (schema) {
self.intents[intentName].schema = schema;
}
};
this.audioPlayerEventHandlers = {};
this.audioPlayer = function(eventName, func) {
self.audioPlayerEventHandlers[eventName] = {
"name": eventName,
"function": func
};
};
this.playbackControllerEventHandlers = {};
this.playbackController = function(eventName, func) {
self.playbackControllerEventHandlers[eventName] = {
"name": eventName,
"function": func
};
};
this.launchFunc = null;
this.launch = function(func) {
self.launchFunc = func;
};
this.sessionEndedFunc = null;
this.sessionEnded = function(func) {
self.sessionEndedFunc = func;
};
this.request = function(request_json) {
var request = new alexa.request(request_json);
var response = new alexa.response(request.getSession());
var postExecuted = false;
var requestType = request.type();
var promiseChain = Promise.resolve();
// attach Promise resolve/reject functions to the response object
response.send = function(exception) {
response.prepare();
var postPromise = Promise.resolve();
if (typeof self.post == "function" && !postExecuted) {
postExecuted = true;
postPromise = Promise.resolve(self.post(request, response, requestType, exception));
}
return postPromise.then(function() {
if (!response.resolved) {
response.resolved = true;
}
return response.response;
});
};
response.fail = function(msg, exception) {
response.prepare();
var postPromise = Promise.resolve();
if (typeof self.post == "function" && !postExecuted) {
postExecuted = true;
postPromise = Promise.resolve(self.post(request, response, requestType, exception));
}
return postPromise.then(function() {
if (!response.resolved) {
response.resolved = true;
throw msg;
}
// propagate successful response if it's already been resolved
return response.response;
});
};
return promiseChain.then(function () {
// Call to `.pre` can also throw, so we wrap it in a promise here to
// propagate errors to the error handler
var prePromise = Promise.resolve();
if (typeof self.pre == "function") {
prePromise = Promise.resolve(self.pre(request, response, requestType));
}
return prePromise;
}).then(function () {
if (!response.resolved) {
if ("IntentRequest" === requestType) {
var intent = request_json.request.intent.name;
if (typeof self.intents[intent] != "undefined" && typeof self.intents[intent]["function"] == "function") {
return Promise.resolve(self.intents[intent]["function"](request, response));
} else {
throw "NO_INTENT_FOUND";
}
} else if ("LaunchRequest" === requestType) {
if (typeof self.launchFunc == "function") {
return Promise.resolve(self.launchFunc(request, response));
} else {
throw "NO_LAUNCH_FUNCTION";
}
} else if ("SessionEndedRequest" === requestType) {
if (typeof self.sessionEndedFunc == "function") {
return Promise.resolve(self.sessionEndedFunc(request, response));
}
} else if (request.isAudioPlayer()) {
var event = requestType.slice(12);
var eventHandlerObject = self.audioPlayerEventHandlers[event];
if (typeof eventHandlerObject != "undefined" && typeof eventHandlerObject["function"] == "function") {
return Promise.resolve(eventHandlerObject["function"](request, response));
}
} else if (request.isPlaybackController()) {
var playbackControllerEvent = requestType.slice(19);
var playbackEventHandlerObject = self.playbackControllerEventHandlers[playbackControllerEvent];
if (typeof playbackEventHandlerObject != "undefined" && typeof playbackEventHandlerObject["function"] == "function") {
return Promise.resolve(playbackEventHandlerObject["function"](request, response));
}
} else {
throw "INVALID_REQUEST_TYPE";
}
}
})
.then(function () {
return response.send();
})
.catch(function(e) {
if (typeof self.error == "function") {
// Default behavior of any error handler is to send a response
return Promise.resolve(self.error(e, request, response)).then(function() {
if (!response.resolved) {
response.resolved = true;
return response.send();
}
// propagate successful response if it's already been resolved
return response.response;
});
} else if (typeof e == "string" && self.messages[e]) {
if (!request.isAudioPlayer()) {
response.say(self.messages[e]);
return response.send(e);
} else {
return response.fail(self.messages[e]);
}
}
if (!response.resolved) {
if (e.message) {
return response.fail("Unhandled exception: " + e.message + ".", e);
} else if (typeof e == "string") {
return response.fail("Unhandled exception: " + e + ".", e);
} else {
return response.fail("Unhandled exception.", e);
}
}
throw e;
});
};
// extract the schema and generate a schema JSON object
this.schema = function() {
var schema = {
"intents": []
},
intentName, intent, key;
for (intentName in self.intents) {
intent = self.intents[intentName];
var intentSchema = {
"intent": intent.name
};
if (intent.schema && intent.schema.slots && Object.keys(intent.schema.slots).length > 0) {
intentSchema["slots"] = [];
for (key in intent.schema.slots) {
intentSchema.slots.push({
"name": key,
"type": intent.schema.slots[key]
});
}
}
schema.intents.push(intentSchema);
}
return JSON.stringify(schema, null, 3);
};
// generate a list of sample utterances
this.utterances = function() {
var intentName,
intent,
out = "";
for (intentName in self.intents) {
intent = self.intents[intentName];
if (intent.schema && intent.schema.utterances) {
intent.schema.utterances.forEach(function(sample) {
var list = AlexaUtterances(sample,
intent.schema.slots,
self.dictionary,
self.exhaustiveUtterances);
list.forEach(function(utterance) {
out += intent.name + " " + (utterance.replace(/\s+/g, " ")).trim() + "\n";
});
});
}
}
return out;
};
// a built-in handler for AWS Lambda
this.handler = function(event, context, callback) {
self.request(event)
.then(function(response) {
callback(null, response);
})
.catch(function(response) {
callback(response);
});
};
// for backwards compatibility
this.lambda = function() {
return self.handler;
};
// attach Alexa endpoint to an express router
//
// @param object options.expressApp the express instance to attach to
// @param router options.router router instance to attach to the express app
// @param string options.endpoint the path to attach the router to (e.g., passing 'mine' attaches to '/mine')
// @param bool options.checkCert when true, applies Alexa certificate checking (default true)
// @param bool options.debug when true, sets up the route to handle GET requests (default false)
// @param function options.preRequest function to execute before every POST
// @param function options.postRequest function to execute after every POST
// @throws Error when router or expressApp options are not specified
// @returns this
this.express = function(options) {
if (!options.expressApp && !options.router) {
throw new Error("You must specify an express app or an express router to attach to.");
}
var defaultOptions = { endpoint: "/" + self.name, checkCert: true, debug: false };
options = defaults(options, defaultOptions);
// In ExpressJS, user specifies their paths without the '/' prefix
var deprecated = options.expressApp && options.router;
var endpoint = deprecated ? '/' : normalizeApiPath(options.endpoint);
var target = deprecated ? options.router : (options.expressApp || options.router);
if (deprecated) {
options.expressApp.use(normalizeApiPath(options.endpoint), options.router);
console.warn("Usage deprecated: Both 'expressApp' and 'router' are specified.\nMore details on https://github.com/alexa-js/alexa-app/blob/master/UPGRADING.md");
}
if (options.debug) {
target.get(endpoint, function(req, res) {
if (typeof req.query['schema'] != "undefined") {
res.set('Content-Type', 'text/plain').send(self.schema());
} else if (typeof req.query['utterances'] != "undefined") {
res.set('Content-Type', 'text/plain').send(self.utterances());
} else {
res.render("test", {
"app": self,
"schema": self.schema(),
"utterances": self.utterances()
});
}
});
}
if (options.checkCert) {
target.use(endpoint, verifier);
} else {
target.use(endpoint, bodyParser.json());
}
// exposes POST /<endpoint> route
target.post(endpoint, function(req, res) {
var json = req.body,
response_json;
// preRequest and postRequest may return altered request JSON, or undefined, or a Promise
Promise.resolve(typeof options.preRequest == "function" ? options.preRequest(json, req, res) : json)
.then(function(json_new) {
if (json_new) {
json = json_new;
}
return json;
})
.then(self.request)
.then(function(app_response_json) {
response_json = app_response_json;
return Promise.resolve(typeof options.postRequest == "function" ? options.postRequest(app_response_json, req, res) : app_response_json);
})
.then(function(response_json_new) {
response_json = response_json_new || response_json;
res.json(response_json).send();
})
.catch(function(err) {
console.error(err);
res.status(500).send("Server Error");
});
});
};
// add the app to the global list of named apps
if (name) {
alexa.apps[name] = self;
}
return this;
};
module.exports = alexa;