diff --git a/CHANGELOG.md b/CHANGELOG.md index ca8bffc..4c978a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * [#364](https://github.com/alexa-js/alexa-app/pull/364): Fix reprompt() to concatenate multiple SSML prompts - [@andrewjhunt](https://github.com/andrewjhunt). * [#371](https://github.com/alexa-js/alexa-app/pull/371): Call response.prepare() after post() - [@fremail](https://github.com/fremail). * [#373](https://github.com/alexa-js/alexa-app/pull/373): Keep multiple newlines in card output - [@kielni](https://github.com/kielni) and [@fremail](https://github.com/fremail). +* [#378](https://github.com/alexa-js/alexa-app/pull/375): Implement CanFulfillIntent requests - [@orlylev](https://github.com/orlylev). * Your contribution here. ### 4.2.2 (April 7, 2018) diff --git a/README.md b/README.md index 5e5f36c..5139901 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ * [Display.ElementSelected](#display-element-selected) * [AudioPlayer Event Request](#audioplayer-event-request) * [PlaybackController Event Request](#playbackcontroller-event-request) + * [CanFulfillIntent Request](#canfulfillintent) * [Other Event Request](#other-event-request) * [Execute Code On Every Request](#execute-code-on-every-request) * [pre()](#pre) @@ -468,7 +469,6 @@ app.displayElementSelected(function(request, response) { handleRequestForTouchEvent(request.selectedElementToken) }) ``` - ### SessionEndRequest ```javascript @@ -555,6 +555,35 @@ app.playbackController('NextCommandIssued', (request, response) => { Note that some device interactions don't always produce PlaybackController events. See the [PlaybackController Interface Introduction](https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/custom-playbackcontroller-interface-reference#introduction) for more details. +### CanFulfillIntent + +Define the handler for canFulfillIntent used for skill discovery. For instance the [Implementation instructions](https://developer.amazon.com/docs/custom-skills/implement-canfulfillintentrequest-for-name-free-interaction.html). + +See detailed explanation on the Intent and Slot [logic](https://developer.amazon.com/docs/custom-skills/understand-name-free-interaction-for-custom-skills.html). + + +```javascript +app.canFulfillHandler(function(request, response) { + // The request object will return CanFulfillIntent object + //by default the response will include "No" for the intent and slots + let canFulfillIntent = request.getCanFulfillIntent(); + + //add your logic to determine if you can fulfill the intent and each slot + if(canFulfillIntent.name == 'HowTo' + || canFulfillIntent.name == 'Information' || canFulfillIntent.name == 'Instructions') + { + //we response YES for these intents that we can fulfill + response.canFulfill("YES"); + } + else if(canFulfillIntent.name == 'GetPrice'){ + //In this information we check if we can answer GetPrice intent with the slots values and decide to answer YES + + //Do the logic ... + response.canFulfillSlot("MyProduct","YES","YES"); + } +}); +``` + ### Other Event Request Handle any new requests that don't have an explicit handler type available (such as new or pre-release features) using the general `on()` and passing the event type. diff --git a/index.js b/index.js index 26b7fb1..f093615 100644 --- a/index.js +++ b/index.js @@ -186,6 +186,65 @@ alexa.response = function(session) { this.sessionObject.clear(key); return this; }; + + this.canFulfillIntent= function(slots){ + this.canFulfillIntentValue = new alexa.canFulfillIntent(self.response.response, slots); + return this; + }; + + this.canFulfill = function(canFulfill){ + if(!this.canFulfillIntentValue){ + this.canFulfillIntentValue = new alexa.canFulfillIntent(self.response.response); + } + this.canFulfillIntentValue.canFulfill(canFulfill); + return this; + }; + + this.canFulfillSlot = function(slotName,canUnderstand, canFulfill){ + if(!this.canFulfillIntentValue){ + this.canFulfillIntentValue = new alexa.canFulfillIntent(self.response.response); + } + this.canFulfillIntentValue.canFulfillSlot(slotName,canUnderstand, canFulfill); + return this; + }; +}; + +alexa.canFulfillIntent = function (response, slots) { + //load alexa slots information from CanFulfillIntentRequest + //create default response + this.canFulfillIntent = { + "canFulfill": "NO", + "slots": {} + }; + + response.canFulfillIntent = this.canFulfillIntent; + if (slots) { + for (let slotName in slots) { + let slotValue = { + "canUnderstand": "NO", + "canFulfill": "NO" + }; + this.canFulfillIntent.slots[slotName] = slotValue; + } + } + + this.canFulfill = function(canFulfill){ + this.canFulfillIntent.canFulfill = canFulfill; + }; + + this.canFulfillSlot = function(slotName,canUnderstand, canFulfill){ + if(this.canFulfillIntent.slots[slotName]){ + let canFulfillSlot = this.canFulfillIntent.slots[slotName]; + canFulfillSlot.canUnderstand = canUnderstand; + canFulfillSlot.canFulfill = canFulfill; + } + else{ + this.canFulfillIntent.slots[slotName] = { + "canUnderstand": canUnderstand, + "canFulfill": canFulfill + }; + } + }; }; alexa.directives = function(directives) { @@ -196,11 +255,15 @@ alexa.directives = function(directives) { this.details.push(directive); }; + + this.clear = function() { this.details.length = 0; }; }; + + alexa.request = function(json) { this.data = json; this.slots = {}; @@ -251,7 +314,9 @@ alexa.request = function(json) { this.context = null; if (this.data.context) { - this.userId = this.data.context.System.user.userId; + if(this.data.context.System && this.data.context.System.user){ + this.userId = this.data.context.System.user.userId; + } this.applicationId = this.data.context.System.application.applicationId; this.context = this.data.context; } @@ -283,6 +348,12 @@ alexa.request = function(json) { this.session = function(key) { return this.getSession().get(key); }; + + this.getCanFulfillIntent = function () { + if (this.data && this.data.request && this.data.request.intent) { + return this.data.request.intent; + } + }; }; alexa.dialog = function(dialogState) { @@ -387,9 +458,13 @@ alexa.session = function(session) { // 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; + this.details.userId = null; + this.details.accessToken = null; + if(this.details.user){ + this.details.userId = this.details.user.userId || null; + 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 @@ -527,6 +602,10 @@ alexa.app = function(name) { this.sessionEnded = function(func) { self.sessionEndedFunc = func; }; + this.canFulfillIntentFunc = null; + this.canFulfillIntent = function(func) { + self.canFulfillIntentFunc = func; + }; this.request = function(request_json) { var request = new alexa.request(request_json); var response = new alexa.response(request.getSession()); @@ -543,7 +622,6 @@ alexa.app = function(name) { postPromise = Promise.resolve(self.post(request, response, requestType, exception)); } return postPromise.then(function() { - response.prepare(); if (!response.resolved) { response.resolved = true; } @@ -558,7 +636,6 @@ alexa.app = function(name) { postPromise = Promise.resolve(self.post(request, response, requestType, exception)); } return postPromise.then(function() { - response.prepare(); if (!response.resolved) { response.resolved = true; throw msg; @@ -618,7 +695,17 @@ alexa.app = function(name) { } else { throw "NO_DISPLAY_ELEMENT_SELECTED_FUNCTION"; } - } else if (typeof self.requestHandlers[requestType] === "function") { + } else if ("CanFulfillIntentRequest" === requestType) { + if (typeof self.canFulfillIntentFunc === "function") { + let reqIntent = request.getCanFulfillIntent(); + if(reqIntent){ + response.canFulfillIntent(reqIntent.slots); + } + return Promise.resolve(self.canFulfillIntentFunc(request, response)); + } else { + throw "NO_CAN_FULFILL_FUNCTION"; + } + }else if (typeof self.requestHandlers[requestType] === "function") { return Promise.resolve(self.requestHandlers[requestType](request, response, request_json)); } else { throw "INVALID_REQUEST_TYPE"; diff --git a/package-lock.json b/package-lock.json index 5de1363..4cf66f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "alexa-app", - "version": "4.2.3", + "version": "4.2.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 314fcdd..2da3faf 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "babel-preset-es2015": "^6.24.1", "chai": "^3.4.1", "chai-as-promised": "^5.3.0", - "chai-string": "1.4.0", + "chai-string": "^1.3.0", "coveralls": "^2.11.9", "danger": "0.6.10", "ejs": "^2.5.5", diff --git a/test/fixtures/can_fulfill_intent_request_no_intent.json b/test/fixtures/can_fulfill_intent_request_no_intent.json new file mode 100644 index 0000000..bee376a --- /dev/null +++ b/test/fixtures/can_fulfill_intent_request_no_intent.json @@ -0,0 +1,37 @@ +{ + "version": "1.0", + "session": { + "new": false, + "sessionId": "amzn1.echo-api.session.abeee1a7-aee0-41e6-8192-e6faaed9f5ef", + "application": { + "applicationId": "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" + }, + "attributes": {}, + "user": { + "userId": "amzn1.account.AM3B227HF3FAM1B261HK7FFM3A2" + } + }, + "context": { + "System": { + "application": { + "applicationId": "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" + }, + "user": { + "userId": "amzn1.account.AM3B227HF3FAM1B261HK7FFM3A2", + "accessToken": "ae312e92d87d7b4e7648b19a08f2966a831a59de" + }, + "device": { + "deviceId": "amzn1.ask.device.123456789", + "supportedInterfaces": {} + }, + "apiEndpoint": "https://api.amazonalexa.com", + "apiAccessToken": "123456789" + } + }, + "request": { + "type": "CanFulfillIntentRequest", + "requestId": "amzn1.echo-api.request.e510e005-c518-44c8-930d-746c84e787da", + "locale": "en-US", + "timestamp": "2018-10-18T09:16:28Z" + } +} \ No newline at end of file diff --git a/test/fixtures/can_fulfill_intent_request_play_sound.json b/test/fixtures/can_fulfill_intent_request_play_sound.json new file mode 100644 index 0000000..30dfd4c --- /dev/null +++ b/test/fixtures/can_fulfill_intent_request_play_sound.json @@ -0,0 +1,46 @@ +{ + "version": "1.0", + "session": { + "new": false, + "sessionId": "amzn1.echo-api.session.abeee1a7-aee0-41e6-8192-e6faaed9f5ef", + "application": { + "applicationId": "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" + }, + "attributes": {}, + "user": { + "userId": "amzn1.account.AM3B227HF3FAM1B261HK7FFM3A2" + } + }, + "context": { + "System": { + "application": { + "applicationId": "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" + }, + "user": { + "userId": "amzn1.account.AM3B227HF3FAM1B261HK7FFM3A2", + "accessToken": "ae312e92d87d7b4e7648b19a08f2966a831a59de" + }, + "device": { + "deviceId": "amzn1.ask.device.123456789", + "supportedInterfaces": {} + }, + "apiEndpoint": "https://api.amazonalexa.com", + "apiAccessToken": "123456789" + } + }, + "request": { + "type": "CanFulfillIntentRequest", + "requestId": "amzn1.echo-api.request.e510e005-c518-44c8-930d-746c84e787da", + "intent": { + "name": "PlaySound", + "slots": { + "Sound": { + "name": "Sound", + "value": "crickets" + } + } + }, + "locale": "en-US", + "timestamp": "2018-10-18T09:16:28Z" + } +} \ No newline at end of file diff --git a/test/test_alexa_app_fulfill_intent_request.js b/test/test_alexa_app_fulfill_intent_request.js new file mode 100644 index 0000000..c651bfc --- /dev/null +++ b/test/test_alexa_app_fulfill_intent_request.js @@ -0,0 +1,292 @@ +/*jshint expr: true*/ +"use strict"; +var chai = require("chai"); +var chaiAsPromised = require("chai-as-promised"); +var mockHelper = require("./helpers/mock_helper"); +chai.use(chaiAsPromised); +var expect = chai.expect; +chai.config.includeStack = true; + +import * as Alexa from '..'; + +describe("Alexa", function () { + describe("app", function () { + var testApp = new Alexa.app("testApp"); + + beforeEach(function () { + testApp = new Alexa.app("testApp"); + }); + + describe("#CanFulfillIntent_not_valid_request", function () { + + it("valid regular intent request getCanFulfillIntent return undefined", function () { + var mockRequest = mockHelper.load("intent_request_airport_info.json"); + var request = testApp.request(mockRequest); + + var handler = function (req, res) { + handler.result = req.getCanFulfillIntent(); + }; + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(handler); + + var subject = request.then(function (response) { + return handler.result; + }); + return expect(subject).to.eventually.become(undefined); + }); + + it("valid regular request getCanFulfillIntent no intent return undefined", function () { + var mockRequest = mockHelper.load("can_fulfill_intent_request_no_intent.json"); + var request = testApp.request(mockRequest); + + var handler = function (req, res){ + handler.result = req.getCanFulfillIntent(); + }; + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(handler); + + var subject = request.then(function (response) { + return handler.result; + }); + return expect(subject).to.eventually.become(undefined); + }); + + it("valid regular request no intent response YES for canFulfill", function () { + var mockRequest = mockHelper.load("can_fulfill_intent_request_no_intent.json"); + var request = testApp.request(mockRequest); + + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(function (req, res) { + res.canFulfill("YES"); + }); + + var subject = request.then(function (response) { + return response.response.canFulfillIntent.canFulfill; + }); + return expect(subject).to.eventually.become('YES'); + }); + + it("valid regular request no intent response YES for canFulfill", function () { + var mockRequest = mockHelper.load("can_fulfill_intent_request_no_intent.json"); + var request = testApp.request(mockRequest); + + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(function (req, res) { + res.canFulfill("YES"); + }); + + var subject = request.then(function (response) { + return response.response.canFulfillIntent.canFulfill; + }); + return expect(subject).to.eventually.become('YES'); + }); + + it("valid regular request no intent response with YES for slot Sound", function () { + var mockRequest = mockHelper.load("can_fulfill_intent_request_no_intent.json"); + var request = testApp.request(mockRequest); + + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(function (req, res) { + res.canFulfillSlot("Sound","YES","YES"); + }); + + var subject = request.then(function (response) { + return response.response.canFulfillIntent.slots; + }); + return expect(subject).to.eventually.become({ + "Sound": { + "canUnderstand": "YES", + "canFulfill": "YES" + } + }); + }); + + }); + + describe("#CanFulfillIntent_request", function () { + + describe("request", function () { + var mockRequest = mockHelper.load("can_fulfill_intent_request_play_sound.json"); + + var request = testApp.request(mockRequest); + beforeEach(function () { + request = testApp.request(mockRequest); + }); + + it("valid request getCanFulfillIntent return intent name PlaySound", function () { + + var handler = function (req, res){ + handler.result = req.getCanFulfillIntent().name; + }; + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(handler); + + var subject = request.then(function (response) { + return handler.result; + }); + return expect(subject).to.eventually.become('PlaySound'); + + }); + + it("valid request getCanFulfillIntent return Sound slot", function () { + + var handler = function (req, res){ + handler.result = req.getCanFulfillIntent().slots; + }; + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(handler); + + var subject = request.then(function (response) { + return handler.result.Sound; + }); + return expect(subject).to.eventually.become({ + "name": "Sound", + "value": "crickets" + }); + }); + }); + + describe("response", function () { + var mockRequest = mockHelper.load("can_fulfill_intent_request_play_sound.json"); + + var request = testApp.request(mockRequest); + beforeEach(function () { + request = testApp.request(mockRequest); + }); + + it("no handler responds with a exception", function () { + var subject = request.then(function (response) { + return response.response; + }); + return expect(subject).to.eventually.be.rejectedWith("NO_CAN_FULFILL_FUNCTION"); + }); + + it("empty handler responds with default NO for canFulfill", function () { + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(function (req, res) { + }); + + var subject = request.then(function (response) { + return response.response.canFulfillIntent.canFulfill; + }); + return expect(subject).to.eventually.become('NO'); + }); + + it("empty handler responds with default NO for slot Sound", function () { + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(function (req, res) { + }); + + var subject = request.then(function (response) { + return response.response.canFulfillIntent.slots; + }); + return expect(subject).to.eventually.become({ + "Sound": { + "canUnderstand": "NO", + "canFulfill": "NO" + } + }); + }); + + it("custom handler responds with default YES for canFulfill", function () { + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(function (req, res) { + res.canFulfill("YES"); + }); + + var subject = request.then(function (response) { + return response.response.canFulfillIntent.canFulfill; + }); + return expect(subject).to.eventually.become('YES'); + }); + + it("custom handler responds with YES for slot Sound", function () { + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(function (req, res) { + res.canFulfillSlot("Sound","YES","YES"); + }); + + var subject = request.then(function (response) { + return response.response.canFulfillIntent.slots; + }); + return expect(subject).to.eventually.become({ + "Sound": { + "canUnderstand": "YES", + "canFulfill": "YES" + } + }); + }); + + it("custom handler responds with canUnderstand YES canFulfill No for slot Sound", function () { + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(function (req, res) { + res.canFulfillSlot("Sound","YES","NO"); + }); + + var subject = request.then(function (response) { + return response.response.canFulfillIntent.slots; + }); + return expect(subject).to.eventually.become({ + "Sound": { + "canUnderstand": "YES", + "canFulfill": "NO" + } + }); + }); + + it("custom handler responds with canUnderstand No canFulfill Yes for slot Sound", function () { + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(function (req, res) { + res.canFulfillSlot("Sound","NO","YES"); + }); + + var subject = request.then(function (response) { + return response.response.canFulfillIntent.slots; + }); + return expect(subject).to.eventually.become({ + "Sound": { + "canUnderstand": "NO", + "canFulfill": "YES" + } + }); + }); + + it("custom handler responds with YES for new slot Play", function () { + testApp.pre = undefined; + testApp.post = undefined; + testApp.canFulfillIntent(function (req, res) { + res.canFulfillSlot("Play","YES","YES"); + }); + + var subject = request.then(function (response) { + return response.response.canFulfillIntent.slots; + }); + return expect(subject).to.eventually.become({ + "Sound": { + "canUnderstand": "NO", + "canFulfill": "NO" + }, + "Play": { + "canUnderstand": "YES", + "canFulfill": "YES" + } + }); + }); + + }); + }); + }); +}); diff --git a/test/test_alexa_app_session.js b/test/test_alexa_app_session.js index 72a143a..a24a172 100644 --- a/test/test_alexa_app_session.js +++ b/test/test_alexa_app_session.js @@ -572,31 +572,5 @@ describe("Alexa", function() { ]); }); }); - - describe("update session in post()", function() { - var mockRequest = mockHelper.load("intent_request_malformed_session.json"); - - it("responds with updated session object", function() { - testApp.pre = function(req) { - if (req.hasSession()) { - req.getSession().set("foo", "bar"); - } - }; - testApp.post = function(req) { - if (req.hasSession()) { - req.getSession().set("foo", "big_bar"); - } - }; - var subject = testApp.request(mockRequest).then(function(response) { - return response.sessionAttributes; - }); - - return Promise.all([ - expect(subject).to.eventually.become({ - "foo": "big_bar" - }) - ]); - }); - }); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index aad9ccc..7d1dd13 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -72,6 +72,9 @@ export class app { sessionEndedFunc?: RequestHandler; sessionEnded: (func: RequestHandler) => void; + canFulfillIntentFunc?: RequestHandler; + canFulfillIntent: (func: RequestHandler) => void; + displayElementSelectedFunc?: RequestHandler; displayElementSelected: (func: RequestHandler) => void; @@ -183,6 +186,9 @@ export class request { /** @deprecated */ session: (key: string) => any; + + /** Returns the request CanFulfillIntent */ + getCanFulfillIntent: () => object; } export class response { @@ -272,6 +278,12 @@ export class response { /** @deprecated */ clearSession: (key?: string) => response; + + canFulfillIntent: (slots?: any) => response; + + canFulfill: (canFulfill: string) => response; + + canFulfillSlot: (slotName: string, canUnderstand: string, canFulfill: string) => response; } // TODO: This is an Amazon-provided interface, but is more of a cluster of a half-dozen different interfaces with no documented parent interface. These are the methods/properties we're actually using. @@ -414,3 +426,10 @@ export interface PlaybackController { name: string; function: RequestHandler; } + +export class canFulfillIntent { + canFulfillIntent: object; + + canFulfill: (canFulfill: string) => void; + canFulfillSlot: (slotName: string , canUnderstand: string , canFulfill: string) => void; +}