Per Lambda für Alexa eine Webseite steuern

Keine Kommentare

Wir haben in den ersten beiden Teilen dieser Serie (Webseiten mit Alexa und Websockets navigieren und Eine Pipeline für Lambda mit AWS CodeStar erstellen) gesehen, wie wir mit dem ASK einen Alexa Skill erstellen und mittels CodeStar eine Deployment Pipeline für unsere Lambda-Funktion erzeugen. In diesem Teil werden wir unsere Lambda so anpassen, dass sie auf unsere Intents des Skills reagiert und mit unserem Webservice kommuniziert, auf dem wir per Websockets die Navigation ermöglichen.

Alexa SDK: Einführung

Wir schauen uns zunächst einmal an, welcher Boilerplate Code in unserer index.js von CodeStar erzeugt wurde.

index.js
'use strict';
 
const Alexa = require('alexa-sdk');
 
const APP_ID = undefined;  // TODO replace with your app ID (OPTIONAL).
 
const languageStrings = {
   ...
};
 
const handlers = {   'LaunchRequest': function () {
       this.emit('GetFact');
   },
   'GetNewFactIntent': function () {
       this.emit('GetFact');
   },
   'GetFact': function () {
       // Get a random space fact from the space facts list
       // Use this.t() to get corresponding language data
       const factArr = this.t('FACTS');
       const factIndex = Math.floor(Math.random() * factArr.length);
       const randomFact = factArr[factIndex];
 
       // Create speech output
       const speechOutput = this.t('GET_FACT_MESSAGE') + randomFact;
       this.emit(':tellWithCard', speechOutput, this.t('SKILL_NAME'), randomFact);
   },
   'AMAZON.HelpIntent': function () {
       const speechOutput = this.t('HELP_MESSAGE');
       const reprompt = this.t('HELP_MESSAGE');
       this.emit(':ask', speechOutput, reprompt);
   },
   'AMAZON.CancelIntent': function () {
       this.emit(':tell', this.t('STOP_MESSAGE'));
   },
   'AMAZON.StopIntent': function () {
       this.emit(':tell', this.t('STOP_MESSAGE'));
   },
   'SessionEndedRequest': function () {
       this.emit(':tell', this.t('STOP_MESSAGE'));
   },
};
 
exports.handler = (event, context) => {   const alexa = Alexa.handler(event, context);
   alexa.APP_ID = APP_ID;
   // To enable string internationalization (i18n) features, set a resources object.
   alexa.resources = languageStrings;
   alexa.registerHandlers(handlers);
   alexa.execute();
};

Wir sehen hier einen fertigen Alexa Skill. Zwei Bereiche sind hier besonders wichtig. Die Definition des sog. „handlers“-Objektes und die Export-Anweisung am Ende der Datei. Zunächst zur Export-Anweisung: Wir exportieren hier die Funktion handler. Diese wird aufgerufen, wenn unsere Lambda-Funktion getriggert wird. Sie bekommt das Event und einen Kontext als Parameter. Wir initialisieren zunächst das Alexa SDK, reichen das Event und den Kontext weiter und registrieren weiter unten die Handler mit alexa.registerHandlers(handlers). Die APP_ID und die Ressourcen sind optionale Dinge, die ich in diesem Tutorial nicht weiter betrachte. Durch das Registrieren der Handler sagen wir dem SDK, welche Intents es verarbeiten soll. Mit alexa.execute() wird die Antwort generiert und an die aufrufende Alexa zurückgesendet.

Nun zum Objekt handlers. In diesem definieren wir die Intents, auf die wir reagieren wollen. Man sieht hier unter anderem die vorgegebenen Intents AMAZON.HelpIntent, AMAZON.Cancellntent und AMAZON.StopIntent. Wofür diese sind, verrät bereits der Name und ich habe es auch im ersten Blogeintrag nochmal beschrieben. Dann finden wir noch den LaunchRequest und den SessionEndedRequest. Der LaunchRequest wird immer dann aufgerufen, wenn unser Skill ohne zusätzliche Anweisung gestartet wird. Also wenn jemand sagen würde: „Alexa, starte Webseiten-Navigator“. Der SessionEndedRequest wird aufgerufen, wenn der Nutzer über 7 Sekunden lang nicht mit dem Skill interagiert.

Dann sehen wir noch den GetNewFactIntent und die Funktion GetFact, welche vom GetNewFactIntent aufgerufen wird. Das Alexa SDK kümmert sich darum, dass die passenden Funktionen aufgerufen werden, je nachdem, was wir in unserem Intent-Schema im Alexa Skills Kit definiert haben und was der Nutzer zur Alexa sagt. Wenn der Nutzer den Skill startet, wird die LaunchRequest-Funktion ausgeführt. Wenn er „Stop“ sagt, wird der AMAZON.StopIntent ausgeführt. Und wenn der Nutzer eine unserer Utterances des NavigateToPage Intents sagt, dann wird unsere Funktion für NavigateToPage aufgerufen. Bisher gibt es die Funktion nicht, was wir aber gleich nachholen. Der Rückgabewert aller dieser Funktionen ist jeweils die Antwort für den Nutzer.

Das Alexa SDK stellt uns mehrere Möglichkeiten bereit, eine Antwort zu erzeugen. Mittels this.emit(‘:ask’, ...) und this.emit(‘:tell’, ...) können wir direkt Antworten ausgeben, wobei der Unterschied ist, dass die „:ask“-Anweisung die Session offen hält und somit auf eine Antwort des Nutzers wartet, während „:tell“ nur einen Text ausgibt und dann die Konversation und damit den Skill beendet. Die sehr gute Dokumentation des Alexa SDKs findet man hier: https://github.com/alexa/alexa-skills-kit-sdk-for-nodejs

Den Fact Skill Boilerplate Code aufräumen

Wir räumen in der index.js zunächst einmal auf und lassen nur das übrig, was wir für den weiteren Verlauf benötigen. Die bereinigte Funktion sieht dann so aus:

index.js
'use strict';
 
const Alexa = require('alexa-sdk');
 
const handlers = {
   'LaunchRequest': function () {
       this.emit(':ask', "Wohin möchtest du navigieren?", "Wohin möchtest du navigieren, sage Home oder Impressum.");
   },
   'NavigateToPage': function () {
 
   },
   'AMAZON.HelpIntent': function () {
       this.emit(':ask', "Wohin möchtest du navigieren?", "Wohin möchtest du navigieren, sage Home oder Impressum.");
   },
   'AMAZON.CancelIntent': function () {
       this.emit(':tell', "Ok, bye");
   },
   'AMAZON.StopIntent': function () {
       this.emit(':tell', "Ok, bye");
   },
   'SessionEndedRequest': function () {
       this.emit(':tell', "Ok, bye");
   },
};
 
exports.handler = (event, context) => {
   const alexa = Alexa.handler(event, context);
   alexa.registerHandlers(handlers);
   alexa.execute();
};

Der LaunchRequest fragt den Nutzer nun, wohin er navigieren möchte und auch die anderen Intents haben eine sinnvolle Antwort. Die beiden Funktionen GetNewFactIntent und GetFact habe ich gelöscht. Wir haben in unserem Skill den NavigateToPage Intent definiert, deswegen habe ich dafür die Funktion NavigateToPage angelegt. Hier wird nun unsere Magie stattfinden, mit der wir die Webseite fernsteuern.

Slot für den PAGE_NAME auslesen

Als nächstes wollen wir wissen, zu welcher Page der Nutzer navigieren möchte.
Wir haben den Slot PAGE_NAME im Alexa Skills Kit definiert. Dieser lässt sich sehr leicht mit dem Alexa SDK abrufen. Wir ergänzen folgende Zeilen in der NavigateToPage Funktion:

index.js
'use strict';
 
const Alexa = require('alexa-sdk');
 
const pageNameToFile = { "home": "index.html", "impressum": "impressum.html"}; 
const handlers = {
   'LaunchRequest': function () {
       ...
   },
   'NavigateToPage': function () {     const pageName = this.event.request.intent.slots.PAGE_NAME.value;     const file = pageNameToFile[pageName.toLowerCase()];   },   ...
};
 
...

Mit this.event.request.intent.slots.PAGE_NAME.value kommen wir an den Slot-Wert heran, den der Nutzer genannt hat. Dieser kann entweder „Home“ oder „Impressum“ sein. In dem Objekt pageNameToFile habe ich diese Seiten auf ihre entsprechenden HTML-Seiten gemappt.

Axios für die Kommunikation zum Webserver einbinden

Um einen Request zu unserem Webserver absetzen zu können, installieren wir uns das npm-Modul axios (https://www.npmjs.com/package/axios) mittels npm i -S axios. Durch const axios = require('axios'); importieren wir das Modul auch gleich in unserer index.js.

Den Request zum Webserver absetzen

index.js
'use strict';
 
const Alexa = require('alexa-sdk');
const axios = require('axios'); 
const pageNameToFile = {
 "home": "index.html",
 "impressum": "impressum.html"
};
 
const handlers = {
   'LaunchRequest': function () {
       ...
   },
   'NavigateToPage': function () {
     const pageName = this.event.request.intent.slots.PAGE_NAME.value;
     const file = pageNameToFile[pageName.toLowerCase()];
 
     axios.get('https://alexa-navigate.now.sh/navigate?file=' + file).then(() => {              this.emit(':ask', "Ok, ich navigiere. Was kann ich noch tun?", "Was kann ich noch tun?");         });   },
   ...
};
 
...

Ich lese den Wert für die Page aus der Map pageNameToFile aus und übergebe ihn anschließend als Parameter mit dem Namen „file“ an den GET-Request zu meinem Webserver. Wenn der Request erfolgreich abgeschlossen ist, soll Alexa noch fragen, was sie als nächstes tun soll.

Das ist auch schon die ganze Magie, die innerhalb der Lambda passiert. Wir triggern mit dem GET-Request unseren Webserver, der unter dem Pfad „/navigate“ erreichbar ist und darauf wartet, dass ihm eine Seite übergeben wird. Den Code pushe ich nun in mein CodeCommit Repository und die Pipeline sorgt dafür, dass meine Lambda aktualisiert wird.

Schauen wir uns nun noch an, wie der Webserver aufgebaut ist:

Webserver erstellen und Websockets konfigurieren

Der Webserver ist eine einfache express app (https://www.npmjs.com/package/express). Sie hat folgende Verzeichnisstruktur:

Webserver Ordner

Der Server wird in der server.js definiert. Die einzelnen Seiten der Page sind index.html und impressum.html. Jede dieser beiden HTML-Dateien bindet die client.js ein.

Die HTML-Dateien sind sehr simpel und sehen wie folgt aus:

home.html
<!doctype html>
<html>
<head>
 <meta charset="utf-8">
 <title></title>
</head>
<body>
<h1>Home</h1>
<script src="js/client.js" async defer></script>
</body>
</html>

Die server.js sieht wie folgt aus:

server.js
const express = require('express');
const WebSocket = require('ws');
const http = require('http');
const path = require("path");
 
const app = express();
 
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
 
let myWebsocket = null;
 
wss.on('connection', function connection(ws, req) {
 myWebsocket = ws;
 console.log("user connected");
});
 
app.use("js", express.static("js"));
 
// e.g. mywebsite.com/navigate?file=impressum.html
app.use('/navigate', function (req, res) { if (myWebsocket && req.query.file) {   myWebsocket.send(JSON.stringify({href: req.query.file})); } res.send('OK');}); 
app.use('/', function (req, res) {
 res.sendFile(path.join(__dirname, req.path));
});
 
server.listen(8080, function listening() {
 console.log('Listening on %d', server.address().port);
});

Ich benutze das npm-Modul „ws“ (https://www.npmjs.com/package/ws), um eine Websocket-Verbindung zwischen Client und Server aufzubauen. Die eigentliche Funktionalität des Servers liegt in der app.use('/navigate') Funktion. Hier sende ich an meinen Websocket-Endpunkt auf dem Client ein JSON-Objekt, das ein einziges Property „href“ besitzt. Diesem Property gebe ich den Wert, den ich aus dem Request auslese (req.query.file). Der restliche Code in der server.js ist typischer express.js Boilerplate Code.

Die client.js hat auch nur ein paar Zeilen Code:

js/client.js
const mySocket = new WebSocket("wss://alexa-navigate.now.sh");
 
mySocket.onopen = function (event) {
 console.log("connected to backend");
};
 
mySocket.onmessage = function (event) { let data = JSON.parse(event.data); window.location.href = data.href;}; 
mySocket.onerror = function (event) {
 console.log(event);
};

Hier wird nur eine neue Websocket-Verbindung zu unserem Webserver unter wss://alexa-navigate.now.sh aufgebaut. Mit mySocket.onmessage() reagieren wir auf eine Nachricht, die vom Server kommt. Unter event.data erhalten wir das JSON-Objekt mit dem „href“-Property, das wir vom Server gesendet haben. Die eigentliche Navigation der Page passiert dann mittels der Zeile window.location.href = data.href. So leicht kann das sein 😉

Webserver deployen mit now

Um meinen Webserver zu deployen, führe ich einfach den Befehl > now in meinem Terminal aus und die Node.js App wird automatisch in meinem kostenfreien Webspace deployt (https://zeit.co/now). Damit meine Seite auch eine feste Subdomain hat, muss ich anschließend auch noch > now alias https://[generated-id].now.sh alexa-navigate ausführen.

Nächste Schritte

Um unsere Anwendung produktiv einsetzen zu können, müsste man natürlich noch ein paar Sachen beachten, die aber den Umfang des Artikels sprengen würden. Z. B. würde die Webseite aktuell für alle Nutzer navigiert werden, die sich gleichzeitig auf der Webseite befinden. Denn der Server sendet an alle Websocket-Clients gleichzeitig die Message. Wir wollen ja eigentlich, dass nur der User, der seine Alexa steuert, auch durch unsere Seite navigiert wird. Dazu müssten wir den Nutzer identifizieren. Das geht einmal über den Alexa Skill, indem wir in unserem Skill das sog. Account Linking integrieren (https://developer.amazon.com/de/docs/custom-skills/link-an-alexa-user-with-a-user-in-your-system.html) und außerdem müsste sich der Nutzer auch zuerst auf unserer Webseite einloggen, damit wir den Account der Alexa mit dem Benutzerkonto auf der Webseite zuordnen können. Anschließend könnten wir nur dem Client des Benutzers eine Websocket Message senden.

Zusammenfassung

In dem Tutorial haben wir gesehen, wie leicht es ist, einen Alexa Skill mit dem Alexa Skills Kit Web-Interface zu definieren und dass mit CodeStar auch sehr leicht die passende Lambda inklusive der Deployment Pipeline erzeugt werden kann.
Innerhalb der Lambda bietet uns das alexa-sdk eine komfortable Möglichkeit, die Antworten für unseren Skill zu definieren. Mit dem npm-Modul ws haben wir im Handumdrehen die Websockets zwischen Client und Server erstellt und mussten nur noch mit ein paar Zeilen JavaScript dafür sorgen, dass unsere Page navigiert, sobald ein bestimmter Endpoint auf unserem Webserver von der Lambda getriggert wird.

Wenn du noch Fragen oder Anregungen zu dieser Lösung hast, dann freue ich mich über Feedback in den Kommentaren.

René Bohrenfeldt

René Bohrenfeldt ist seit mehr als 10 Jahren in der Software-Entwicklung tätig und fokussiert sich dabei hauptsächlich auf Frontend-Technologien. Bei der codecentric AG setzt er als IT-Consultant sowohl sein Entwicklungs-Know-how als auch seine Kommunikationsstärke als PO und Moderator ein.
Darüber hinaus engagiert sich der studierte Betriebswirt unternehmerisch und ist Mitgründer einer Busvermietung in Berlin.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.