Repository: alexa-js/alexa-app (js)

Issue:
Way to switch intents?
Is there a way to trigger/switch an intent manually? 

My use case:
An intent is triggered. 
Logic is performed that decides if a user has access to this particular intent. 
If not, then pass the user to another intent on the alexa service and continue the interaction. 


Pull request description:
Router
Hello!

I implemented a router requested in #262.
Actually I brought most code of the router from the incoming request routing based on request type.

In two words, I did two things:

1) Added ability to route request to intent/AudioPlayer event handler/PlaybackController event handler/custom request handler and other request types.
`request.getRouter().launch();`
`request.getRouter().sessionEnded();`
`request.getRouter().intent("oneIntent");`
`request.getRouter().custom("sayHello");`
`request.getRouter().audioPlayer("PlaybackNearlyFinished");`
`request.getRouter().playbackController("NextCommandIssued");`
`request.getRouter().displayElementSelected();`

2) Simplify incoming request routing by using the new router.

resolves #262, #102 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca8bffc..280cde4 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).
+* [#374](https://github.com/alexa-js/alexa-app/pull/374): Added Router - [@fremail](https://github.com/fremail).
 * Your contribution here.
 
 ### 4.2.2 (April 7, 2018)
diff --git a/README.md b/README.md
index 551b890..fd31bda 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@
     * [slot](#slot)
     * [slotResolution](#slotResolution)
     * [resolutionValue](#resolutionValue)
+    * [router](#router)
 * [Request Handlers](#request-handlers)
     * [LaunchRequest](#launchrequest)
     * [IntentRequest](#intentrequest)
@@ -186,6 +187,9 @@ Boolean request.hasSession()
 // return the session object
 Session request.getSession()
 
+// return the router object
+Router request.getRouter()
+
 // return the request context
 request.context
 
@@ -328,6 +332,34 @@ String session.get(String attributeName)
 session.details = { ... }
 ```
 
+### router
+```javascript
+// get router object
+// every method of the router returns a promise, so you can chain them or just return it back to alexa-app
+var router = request.getRouter()
+
+// route request to 'MySuperIntent' intent handler
+Promise router.intent('MySuperIntent')
+
+// route request to Launch handler
+Promise router.launch()
+
+// route request to session ended handler
+Promise router.sessionEnded()
+
+// route request to audio player handler
+Promise router.audioPlayer('PlaybackNearlyFinished')
+
+// route request to playback controller handler
+Promise router.playbackController('NextCommandIssued')
+
+// route request to display element selected handler
+Promise router.displayElementSelected()
+
+// route request to custom handler
+Promise router.custom('CrazyCustomEvent')
+```
+
 ### slot
 ```javascript
 // get the slot object
@@ -380,6 +412,8 @@ String resolutionValue.id
 
 Your app can define a single handler for the `Launch` event and the `SessionEnded` event, and multiple intent handlers.
 
+For switching intents, redirecting from one handler to other and other routing tasks you can use [router](#router).
+
 ### LaunchRequest
 
 ```javascript
diff --git a/index.js b/index.js
index 26b7fb1..ca53cc1 100644
--- a/index.js
+++ b/index.js
@@ -98,7 +98,7 @@ alexa.response = function(session) {
 
     // remove all SSML to keep the card clean
     clenseAttrs.forEach(function(idx) {
-      oCard[idx] = SSML.cleanse(oCard[idx]);
+      oCard[idx] = SSML.cleanse(oCard[idx], true);
     });
 
     this.response.response.card = oCard;
@@ -411,6 +411,58 @@ alexa.session = function(session) {
   };
 };
 
+alexa.router = function(app, request, response, request_json) {
+  this.intent = function(intent) {
+    if (typeof app.intents[intent] !== "undefined" && typeof app.intents[intent].handler === "function") {
+      if (app.intents[intent].isDelegatedDialog() && !request.getDialog().isCompleted()) {
+        return Promise.resolve(request.getDialog().handleDialogDelegation(request, response));
+      } else {
+        return Promise.resolve(app.intents[intent].handler(request, response));
+      }
+    } else {
+      throw "NO_INTENT_FOUND";
+    }
+  };
+  this.launch = function() {
+    if (typeof app.launchFunc === "function") {
+      return Promise.resolve(app.launchFunc(request, response));
+    } else {
+      throw "NO_LAUNCH_FUNCTION";
+    }
+  };
+  this.sessionEnded = function() {
+    if (typeof app.sessionEndedFunc === "function") {
+      return Promise.resolve(app.sessionEndedFunc(request, response));
+    }
+  };
+  this.audioPlayer = function(event) {
+    var eventHandlerObject = app.audioPlayerEventHandlers[event];
+    if (typeof eventHandlerObject !== "undefined" && typeof eventHandlerObject["function"] === "function") {
+      return Promise.resolve(eventHandlerObject["function"](request, response));
+    }
+  };
+  this.playbackController = function(event) {
+    var playbackEventHandlerObject = app.playbackControllerEventHandlers[event];
+    if (typeof playbackEventHandlerObject !== "undefined" && typeof playbackEventHandlerObject["function"] === "function") {
+      return Promise.resolve(playbackEventHandlerObject["function"](request, response));
+    }
+  };
+  this.displayElementSelected = function() {
+    if (typeof app.displayElementSelectedFunc === "function") {
+      return Promise.resolve(app.displayElementSelectedFunc(request, response));
+    } else {
+      throw "NO_DISPLAY_ELEMENT_SELECTED_FUNCTION";
+    }
+  };
+  this.custom = function(requestType) {
+    if (typeof app.requestHandlers[requestType] === "function") {
+      return Promise.resolve(app.requestHandlers[requestType](request, response, request_json));
+    } else {
+      throw "NO_CUSTOM_REQUEST_HANDLER";
+    }
+  };
+};
+
 alexa.apps = {};
 
 alexa.app = function(name) {
@@ -429,6 +481,8 @@ alexa.app = function(name) {
     "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 was routed to custom handler, but no such handler was defined
+    "NO_CUSTOM_REQUEST_HANDLER": "Error: no custom request handler found",
     // 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",
@@ -530,10 +584,14 @@ alexa.app = function(name) {
   this.request = function(request_json) {
     var request = new alexa.request(request_json);
     var response = new alexa.response(request.getSession());
+    var router = new alexa.router(self, request, response, request_json);
     var postExecuted = false;
     var requestType = request.type();
     var promiseChain = Promise.resolve();
 
+    request.getRouter = function() {
+      return router;
+    };
     // attach Promise resolve/reject functions to the response object
     response.send = function(exception) {
       response.prepare();
@@ -581,45 +639,21 @@ alexa.app = function(name) {
         if (!response.resolved) {
           if ("IntentRequest" === requestType) {
             var intent = request_json.request.intent.name;
-            if (typeof self.intents[intent] !== "undefined" && typeof self.intents[intent].handler === "function") {
-              if (self.intents[intent].isDelegatedDialog() && !request.getDialog().isCompleted()) {
-                return Promise.resolve(request.getDialog().handleDialogDelegation(request, response));
-              } else {
-                return Promise.resolve(self.intents[intent].handler(request, response));
-              }
-            } else {
-              throw "NO_INTENT_FOUND";
-            }
+            return request.getRouter().intent(intent);
           } else if ("LaunchRequest" === requestType) {
-            if (typeof self.launchFunc == "function") {
-              return Promise.resolve(self.launchFunc(request, response));
-            } else {
-              throw "NO_LAUNCH_FUNCTION";
-            }
+            return request.getRouter().launch();
           } else if ("SessionEndedRequest" === requestType) {
-            if (typeof self.sessionEndedFunc == "function") {
-              return Promise.resolve(self.sessionEndedFunc(request, response));
-            }
+            return request.getRouter().sessionEnded();
           } 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));
-            }
+            return request.getRouter().audioPlayer(event);
           } 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));
-            }
+            return request.getRouter().playbackController(playbackControllerEvent);
           } else if ("Display.ElementSelected" === requestType) {
-            if (typeof self.displayElementSelectedFunc === "function") {
-              return Promise.resolve(self.displayElementSelectedFunc(request, response));
-            } else {
-              throw "NO_DISPLAY_ELEMENT_SELECTED_FUNCTION";
-            }
+            return request.getRouter().displayElementSelected();
           } else if (typeof self.requestHandlers[requestType] === "function") {
-            return Promise.resolve(self.requestHandlers[requestType](request, response, request_json));
+            return request.getRouter().custom(requestType);
           } else {
             throw "INVALID_REQUEST_TYPE";
           }
diff --git a/types/index.d.ts b/types/index.d.ts
index aad9ccc..e76ac77 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -183,6 +183,9 @@ export class request {
 
   /** @deprecated */
   session: (key: string) => any;
+
+  /** Returns router object to switch request to some intent, event handler, etc. */
+  getRouter: () => router;
 }
 
 export class response {
@@ -315,6 +318,18 @@ export class session {
   getAttributes: () => any;
 }
 
+export class router {
+  constructor(app: any, request: alexa.Request, response: alexa.Response, request_json: string)
+
+  intent: (intent: string) => Promise<response>;
+  launch: () => Promise<response>;
+  sessionEnded: () => Promise<response>;
+  audioPlayer: (event: string) => Promise<response>;
+  playbackController: (event: string) => Promise<response>;
+  displayElementSelected: () => Promise<response>;
+  custom: (requestType: string) => Promise<response>;
+}
+
 export class resolutionValue {
   constructor(value: alexa.ResolutionValue);
 


Test patch:
diff --git a/test/test_alexa_app_cards.js b/test/test_alexa_app_cards.js
index 0f2ee80..f12c24f 100644
--- a/test/test_alexa_app_cards.js
+++ b/test/test_alexa_app_cards.js
@@ -226,6 +226,29 @@ describe("Alexa", function() {
             });
 
           });
+
+          describe("intent handler with multi-line card", function() {
+            it("responds with a card keeping multi lines", function () {
+              var cardTitle = "Card title";
+              var cardContent = "This is my card!\n\nWith some content.";
+              testApp.intent("airportInfoIntent", {}, function (req, res) {
+                res.say(expectedMessage).card(cardTitle, cardContent);
+                return true;
+              });
+
+              var subject = testApp.request(mockRequest);
+              var cardResponse = subject.then(function (response) {
+                return response.response.card;
+              });
+              return Promise.all([
+                expect(cardResponse).to.eventually.become({
+                  "content": cardContent,
+                  "title": cardTitle,
+                  "type": "Simple",
+                }),
+              ]);
+            });
+          });
         });
       });
     });
diff --git a/test/test_alexa_app_router.js b/test/test_alexa_app_router.js
new file mode 100644
index 0000000..8cd1f44
--- /dev/null
+++ b/test/test_alexa_app_router.js
@@ -0,0 +1,219 @@
+/*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("airportInfoIntent request", function() {
+      var mockRequest = mockHelper.load("intent_request_airport_info.json");
+
+      context("route request to welcome intent", function() {
+        it("output speech contains text from both intents", function() {
+
+          testApp.intent("airportInfoIntent", {}, function (req, res) {
+            res.say("It's airportInfoIntent");
+            return req.getRouter().intent("welcome");
+          });
+
+          testApp.intent("welcome", {}, function (req, res) {
+            res.say("and welcome!");
+          });
+
+          var speech = testApp.request(mockRequest).then(function (response) {
+            return response.response.outputSpeech;
+          });
+
+          return Promise.all([
+            expect(speech).to.eventually.become({
+              ssml: "<speak>It's airportInfoIntent and welcome!</speak>",
+              type: "SSML"
+            })
+          ]);
+        });
+      });
+
+      context("route request to undefined intent", function() {
+        it("output speech contains NO_INTENT_FOUND response", function() {
+
+          testApp.intent("airportInfoIntent", {}, function (req, res) {
+            return req.getRouter().intent("noSuchIntent");
+          });
+
+          var speech = testApp.request(mockRequest).then(function (response) {
+            return response.response.outputSpeech;
+          });
+
+          return Promise.all([
+            expect(speech).to.eventually.become({
+              ssml: "<speak>" + testApp.messages.NO_INTENT_FOUND + "</speak>",
+              type: "SSML"
+            })
+          ]);
+        });
+      });
+
+      context("route request to launch function", function() {
+        it("output speech contains text from launch callback", function() {
+
+          testApp.launch(function(req, res) {
+            res.clear();
+            res.say("Welcome to Airport Info Skill");
+          });
+          testApp.intent("airportInfoIntent", {}, function (req, res) {
+            res.say("It's airportInfoIntent");
+            return req.getRouter().launch();
+          });
+
+          var speech = testApp.request(mockRequest).then(function (response) {
+            return response.response.outputSpeech;
+          });
+
+          return Promise.all([
+            expect(speech).to.eventually.become({
+              ssml: "<speak>Welcome to Airport Info Skill</speak>",
+              type: "SSML"
+            })
+          ]);
+        });
+      });
+
+      context("route request to custom request handler", function() {
+        it("output speech contains text from sayHello custom request handler", function() {
+
+          testApp.on("sayHello", function(req, res) {
+            res.clear();
+            res.say("Hello!");
+          });
+          testApp.intent("airportInfoIntent", {}, function (req, res) {
+            res.say("It's airportInfoIntent");
+            return req.getRouter().custom("sayHello");
+          });
+
+          var speech = testApp.request(mockRequest).then(function (response) {
+            return response.response.outputSpeech;
+          });
+
+          return Promise.all([
+            expect(speech).to.eventually.become({
+              ssml: "<speak>Hello!</speak>",
+              type: "SSML"
+            })
+          ]);
+        });
+      });
+
+      context("route request to NextCommandIssued handler of playbackController through PlaybackNearlyFinished", function() {
+        var stream = {
+          "url": "https://next-song-url",
+          "token": "some_token",
+          "expectedPreviousToken": undefined,
+          "offsetInMilliseconds": 0
+        };
+
+        beforeEach(function() {
+          testApp.intent("airportInfoIntent", {}, function (req, res) {
+            res.say("It's airportInfoIntent");
+            return req.getRouter().playbackController("NextCommandIssued");
+          });
+
+          testApp.playbackController("NextCommandIssued", function (req) {
+            return req.getRouter().audioPlayer("PlaybackNearlyFinished");
+          });
+
+          testApp.audioPlayer("PlaybackNearlyFinished", function (req, res) {
+            res.audioPlayerPlayStream("REPLACE_ALL", stream);
+          });
+        });
+
+        it("output speech contains text from airportInfoIntent", function() {
+
+          var speech = testApp.request(mockRequest).then(function (response) {
+            return response.response.outputSpeech;
+          });
+
+          return Promise.all([
+            expect(speech).to.eventually.become({
+              ssml: "<speak>It's airportInfoIntent</speak>",
+              type: "SSML"
+            })
+          ]);
+        });
+
+        it("and AudioPlayer directive in response from PlaybackNearlyFinished AudioPlayer event handler", function() {
+
+          var speech = testApp.request(mockRequest).then(function (response) {
+            return response.response.directives[0].audioItem.stream;
+          });
+
+          return Promise.all([
+            expect(speech).to.eventually.become(stream)
+          ]);
+        });
+      });
+
+      context("chain several routes to count from one to five", function() {
+        it("output speech contains text from airportInfoIntent, oneIntent, twoIntent, threeIntent, fourIntent and fiveIntent", function() {
+
+          testApp.intent("airportInfoIntent", {}, function (req, res) {
+            res.say("Let's count!");
+            return req.getRouter().intent("oneIntent").then(function() {
+              return req.getRouter().intent("twoIntent");
+            }).then(function() {
+              return req.getRouter().intent("threeIntent");
+            }).then(function() {
+              return req.getRouter().intent("fourIntent");
+            }).then(function() {
+              return req.getRouter().intent("fiveIntent");
+            });
+          });
+
+          testApp.intent("oneIntent", function (req, res) {
+            res.say("one...");
+          });
+
+          testApp.intent("twoIntent", function (req, res) {
+            res.say("two...");
+          });
+
+          testApp.intent("threeIntent", function (req, res) {
+            res.say("three...");
+          });
+
+          testApp.intent("fourIntent", function (req, res) {
+            res.say("four...");
+          });
+
+          testApp.intent("fiveIntent", function (req, res) {
+            res.say("five...");
+          });
+
+          var speech = testApp.request(mockRequest).then(function (response) {
+            return response.response.outputSpeech;
+          });
+
+          return Promise.all([
+            expect(speech).to.eventually.become({
+              ssml: "<speak>Let's count! one... two... three... four... five...</speak>",
+              type: "SSML"
+            })
+          ]);
+        });
+      });
+
+
+    });
+  });
+});