Amazon Echo Spotで発言をSlackに記録＆画面に表示するスキルを作成してみた

# 開発経緯
Alexaと遊んでいると適切に発言を聞き取ってもらえないことが多々あり、発言内容を手軽に確認したいと思っていました。

[Alexaを使ってSlackにメモを保存する](https://qiita.com/dexia/items/8ed5d0c3d485525010da)を参考に発言内容をSlackに記録する部分を作成したので、さらにAmazon Echo Spotで画面上で発言内容を確認できるようにしました。

これで意図した発言内容とAlexaの解析結果のズレが簡単に確認できるはずです。

今回は、スマートスピーカー初心者が以下の点を中心にスキル開発の流れを記載しています。

* 既存スキルを簡単にSpotに対応させること
* [Alexaを使ってSlackにメモを保存する](https://qiita.com/dexia/items/8ed5d0c3d485525010da)を参考にスキル開発をしてハマったところ

多少見づらい点があるかと思いますが、コメント等でフィードバックをいただけたら嬉しいです。

# スキルの流れ
![image.png](https://qiita-image-store.s3.amazonaws.com/0/245399/306803d6-e302-ae3f-bf48-a6dd6a0d98b2.png)
Lambdaでaxiosを使ってHTTP通信をすることでSlackを叩きます。

# スキル作成

## 画像インターフェースの有効化
__Amazon開発者ポータルで「インターフェース」の「画像インターフェース」をON__にして再ビルドするだけです。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/245399/761ed4b8-6b39-58e7-ed2c-0ee5b9ccaca5.png)
有効化するとビルドインインターフェースが沢山増えますが、今回は何も変更せずビルドし直すだけで良いです。


## スキーマの作成
[ユーザーの自由発話に対応する Alexa Skill を作る](https://qiita.com/halhorn/items/48998d6b7a166cc905c6)を参考に発話を受け取る対話モデルを作成します。
スキル名は「スラックメモ」にしました。
画像インターフェースを有効化したので、上記の記事と比べてインテントが増えています。

```json
{
    "interactionModel": {
        "languageModel": {
            "invocationName": "スラックメモ",
            "intents": [
                {
                    "name": "AMAZON.HelpIntent",
                    "samples": []
                },
                {
                    "name": "MemoIntent",
                    "slots": [
                        {
                            "name": "Memo",
                            "type": "any"
                        }
                    ],
                    "samples": [
                        "{Memo}"
                    ]
                },
                {
                    "name": "AMAZON.MoreIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.NavigateHomeIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.NavigateSettingsIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.NextIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.PageUpIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.PageDownIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.PreviousIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.ScrollRightIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.ScrollDownIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.ScrollLeftIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.ScrollUpIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.StopIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.CancelIntent",
                    "samples": []
                }
            ],
            "types": [
                {
                    "name": "any",
                    "values": [
                        {
                            "name": {
                                "value": "ほげほげ"
                            }
                        }
                    ]
                }
            ]
        }
    }
}

```

## AWS lambda関数の作成
言語はNode.jsで実装しました。Slackに記録する部分は[Alexaを使ってSlackにメモを保存する](https://qiita.com/dexia/items/8ed5d0c3d485525010da)と同様に行いました。

### 環境変数
* APP_ID  
スキルのID。Amazon開発者ポータルから確認できる。  
* BOT_TOKEN  
Slackのトークン。[Slack APIのTokenの取得・場所](https://qiita.com/ykhirao/items/0d6b9f4a0cc626884dbb)を参考に取得。
* CHANNEL  
投稿先のチャンネル名。

### ライブラリ
今回、HTTP通信のためにaxiosを使ったので、[こちら](https://qiita.com/makopo/items/b738e32081f8cae75fe7)を参考にLambda関数を「一から作成」しました。
以下のコマンドを叩き、pakage.jsonを作成します。

```
npm init
npm install --save alexa-sdk axios
```
そして、下記のindex.jsを作成し、zipで圧縮します。
そのzipを関数コードとしてアップロードしました。

```javascript:index.js
"use strict";
const Alexa = require('alexa-sdk');
const axios = require('axios');

const makePlainText = Alexa.utils.TextUtils.makePlainText;
const makeRichText = Alexa.utils.TextUtils.makeRichText;
const makeImage = Alexa.utils.ImageUtils.makeImage;

var SUCCESS_MESSAGE = "をSlackに送ったよ";
var HELP_MESSAGE = "何かしゃべってね";
var FAILED_MESSAGE = "Slackに送れなかったよ、ごめんね";
var EXPLANATION_MESSAGE = "Slackに記録するよ";

var TITEL = 'Slack Memo';
var LISTEN_IMAGE_URL = 'https://hoge/hoge.png';
var RESULT_IMAGE_URL = 'https://foo/foo.png';

function supportsDisplay() {
    var hasDisplay =
        this.event.context &&
        this.event.context.System &&
        this.event.context.System.device &&
        this.event.context.System.device.supportedInterfaces &&
        this.event.context.System.device.supportedInterfaces.Display
    return hasDisplay;
}

var handlers = {
    'LaunchRequest': function () {
        this.emit('AMAZON.HelpIntent');
    },
    'AMAZON.HelpIntent': function () {
        this.response.speak(HELP_MESSAGE)
            .listen(EXPLANATION_MESSAGE + HELP_MESSAGE);

        if (supportsDisplay.call(this)) {
            const builder = new Alexa.templateBuilders.BodyTemplate1Builder();
            const template = builder.setTitle(TITEL)
                .setBackgroundImage(makeImage(LISTEN_IMAGE_URL))
                .build();
            this.response.renderTemplate(template);
        }
        this.emit(':responseReady');
    },
    'MemoIntent': function () {
        var memo = this.event.request.intent.slots.Memo.value;
        console.log("memo:" + memo);

        if (supportsDisplay.call(this)) {
            const builder = new Alexa.templateBuilders.BodyTemplate1Builder();
            const template = builder.setTitle(TITEL)
                .setBackgroundImage(makeImage(RESULT_IMAGE_URL))
                .setTextContent(makeRichText('' + memo + ''), null, null)
                .build();
            this.response.renderTemplate(template);
        }

        if (!memo) {
            this.response.speak(FAILED_MESSAGE);
            this.emit(':responseReady');
            return;
        }

        axios.get(
            'https://slack.com/api/chat.postMessage',
            { 
                params:{
                    token: process.env.BOT_TOKEN,
                    channel: process.env.CHANNEL,
                    text: memo
                }
            })
            .then(res => {
                this.response.speak(memo + SUCCESS_MESSAGE);
                this.emit(':responseReady');
            })
            .catch(err => {
                this.response.speak(FAILED_MESSAGE);
                this.emit(':responseReady');
            });
    }
};

exports.handler = function (event, context, callback) {
    var alexa = Alexa.handler(event, context);
    alexa.appId = process.env.APP_ID;
    alexa.registerHandlers(handlers);
    alexa.execute();
};
```


今回は発言内容を画面上に表示したいので、ディスプレイディレクティブを作成しました。
[日本語版 Echo Spot に対応したスキルの作り方](https://developer.amazon.com/ja/blogs/alexa/post/7989cf68-e611-4d88-9ffb-3fca677214bf/building-skills-for-echo-spot-customers-in-japan1)を参考にコードの説明を少し記載します。

### ディスプレイ付きデバイスかどうか判断する
下記の関数で、ユーザーのデバイスがディスプレイがあるか判断しています。

```js
function supportsDisplay() {
    var hasDisplay =
        this.event.context &&
        this.event.context.System &&
        this.event.context.System.device &&
        this.event.context.System.device.supportedInterfaces &&
        this.event.context.System.device.supportedInterfaces.Display
    return hasDisplay;
}
```
Amazon開発者ポータルの「テスト」から先ほど作成したスキルを「Alexaシュミレータ」でテストすると分かるのですが、画像インターフェースの有効化を有効化にすると「スキルI/O」の「JSON入力」にある`supportedInterfaces`ノードの下に__Displayノードが新たに作成__されていることが分かります。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/245399/686d9970-aa2c-e29a-ce91-34b1801aaa90.png)

よって、上記の関数では`Display`ノードまでの各ノードに値が入っていたらtrueが返るようになっているため、デバイスの画面の有無を確認できます。

### ディスプレイディレクティブを作成する
インテントの関数の中で`call`メソッドを使って上記の`supportsDisplay`関数を呼び出すことで、ディスプレイの有無を確認してからディスプレイディレクティブを作成しています。
[こちら](https://qiita.com/HeRo/items/8475505a55cc9e737687)にも記載があるように__ディスプレイの無いデバイスに`Display.RenderTemplate`ディレクティブを送るとエラーが起こり、スキルが上手く動いてくれません。__

ディスプレイテンプレートは、「BodyTemplate1」を使いました。
他のテンプレートを知りたい場合は、[Displayテンプレートのリファレンス](https://developer.amazon.com/ja/docs/custom-skills/display-template-reference.html)を見ると良いです。

テンプレートにはタイトル、背景の画像、テキストを設定しました。
今回はAlexaが問いかけている時と発言を画面に表示する時で画像を変えたかったので、2回ディスプレイディレクティブを作成しています。

[こちら](https://www.aguuu.com/archives/2018/05/alexa_display_interface/)によれば__文字列はデフォルトで白__になっており、明るい画像を背景にすると文字が見えづらくなるため注意が必要です。

`Display.RenderTemplate`ディレクティブは、[Displayインターフェースのリファレンス](https://developer.amazon.com/ja/docs/custom-skills/display-interface-reference.html)を参考にしました。




```js
        if (supportsDisplay.call(this)) {
            const builder = new Alexa.templateBuilders.BodyTemplate1Builder();
            const template = builder.setTitle(TITEL)
                .setBackgroundImage(makeImage(RESULT_IMAGE_URL))
                .setTextContent(makeRichText('' + memo + ''), null, null)
                .build();
            this.response.renderTemplate(template);
        }
```

## ハマったところ
* zipのアップロードが上手くいかない問題
Windows環境で作業しているのですが、7-Zipの`7z a sample.zip *`コマンドで圧縮したら上手くいきました。Windowsの標準機能だと上手くいきませんでした。

* 発言が途中で切れてしまう問題
最初は下記のコードのように変数に話す内容を入れておいて最後に`emit`メソッドを叩こうと思ってました。

```js
        axios.get(
            'https://slack.com/api/chat.postMessage',
            { 
                params:{
                    token: process.env.BOT_TOKEN,
                    channel: process.env.CHANNEL,
                    text: memo
                }
            })
            .then(res => {
                console.log("SUCCESS_MESSAGE:" + SUCCESS_MESSAGE);
                speachOutput = memo + SUCCESS_MESSAGE;
            })
            .catch(err => {
                console.log("FAILED_MESSAGE:" + FAILED_MESSAGE);
                speachOutput = FAILED_MESSAGE;
            });

        this.response.speak(speachOutput);
        this.emit(':responseReady');
```
しかし、これだと`memo`の内容のみで`speachOutput`の内容を話してくれませんでした。
そこで、下記のコードのように`get`が成功した時失敗した時それぞれで`emit`メソッドを叩くと上手くいきました。
非同期処理が関係してるのかもしれません。

```js
axios.get(
            'https://slack.com/api/chat.postMessage',
            { 
                params:{
                    token: process.env.BOT_TOKEN,
                    channel: process.env.CHANNEL,
                    text: memo
                }
            })
            .then(res => {
                this.response.speak(memo + SUCCESS_MESSAGE);
                this.emit(':responseReady');
            })
            .catch(err => {
                this.response.speak(FAILED_MESSAGE);
                this.emit(':responseReady');
            });
```


* Lambda関数の定数の文字化け問題  
メッセージの定数が文字化けしており、Alexaが上手く喋ってくれませんでした。原因は、index.jsファイルの文字コードが「ANSI」だからでした。「UTF-8」にしたら文字化けしなくなりました。zipでコードを上げるときは文字コードは「UTF-8」にした方が良さそうです。


## 課題
発言内容がSlackではなく、スプレッドシートに出力されれば解析などにも使えて面白いかなと思ってます。