Alexaスキル開発チュートリアル(AWS Lamda, node.js）

# Alexaスキル開発チュートリアル(AWS Lamda, node.js）
個人で開発した、Alexaスキル開発チュートリアルを記載します。

**今回は自分の目標設定をAlexaに記憶させて、次回起動時に目標期限を自動で算出して教えてくれるAlexaスキルを開発してみようと思います。**

### 完成イメージ

```
◼︎ 初回起動時
(自分)アレクサ、目標リマインダーを開いて
(Alexa)こんにちは、目標リマインダーへようこそ。目標達成希望日時を教えてください。
(自分)2020年10月9日
(Alexa)ありがとうございます。目標達成日時は2020年10月９日ですね。

◼︎ 次回起動時
(自分)アレクサ、目標リマインダー
(Alexa)おかえりなさい、次の目標達成日時まで１年、残り１２日です。目標達成に向けて本日も頑張りましょう
```

### 前提知識
```
1. AWS Lambdaについての最低限の知識
2. JavsaScriptについての最低限の知識
3. Alexa(スマートスピーカー)についての最低限の知識
```

## STEP1 「ログイン」
まず、[Amazon開発者アカウント](https://developer.amazon.com/alexa/console/ask)を使って Alexa開発者コンソールにログインします。アカウントがない場合は、[こちら](https://www.amazon.com/ap/register?clientContext=131-0331464-9465436&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&siteState=clientContext%3D142-6935021-1894360%2CsourceUrl%3Dhttps%253A%252F%252Fdeveloper.amazon.com%252Falexa%2Csignature%3Doyixlki7Yxz8bRUtt4vGJ4EugQ8j3D&marketPlaceId=ATVPDKIKX0DER&language=en_US&pageId=amzn_developer_portal&openid.return_to=https%3A%2F%2Fdeveloper.amazon.com%2Falexa&prevRID=HSRBQ1KHA4E5D1PBHPPP&openid.assoc_handle=mas_dev_portal&openid.mode=checkid_setup&prepopulatedLoginId=&failedSignInCount=0&openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0)で作成します。

## STEP2 「スキルを作成」
![スクリーンショット 2021-03-09 10.49.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/e79f5735-99ca-6746-e488-de663014b61e.png)
コンソールの右側にあるスキルの作成をクリックします。新しいページが表示されます。

![FireShot Capture 020 - Alexa Developer Console - developer.amazon.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/6181ac3a-c2c5-0b4f-0649-6a556f1ce78d.png)

1. スキル名を入力します。このスキル名が公開された時に表示される名前になります。
2. スキルに追加するモデルを選択します。今回はオリジナルAlexaスキルを開発するのでカスタムを選択します。
3. スキルのバックエンドリソースをホスティングする方法を選択します。今回はAlexaにホストを選択します。今回の開発言語はNode.jsにする。（AWSの無料枠には制限があるため、スキルの使用頻度が上がってきたら、独自にホスティングするオプションに変更するのがオススメ）

## STEP3 「呼び出し名を作成」
呼び出し名とは自分が開発したAlexaスキルを呼び出すときに発生するワードになります。

```
アレクサ、目標リマインダーを開いて
```

この時の {{ 目標リマインダー }}が呼び出し名になります。

## STEP4 「コードエディタでコードを書く」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/af7d92db-b25a-4f32-2029-accb52fd79b4.png)

コードエディタタブをクリックします。コードエディタでindex.jsファイルが開きます。

今回開発した「目標設定リマインダー」の完成コードを記載します。

全てのコードを詳しく解説すると、永遠に終わらないので簡単な解説コメントのみ記載します


``` index.js
/* *
 * This sample demonstrates handling intents from an Alexa skill using the Alexa Skills Kit SDK (v2).
 * Please visit https://alexa.design/cookbook for additional examples on implementing slots, dialog management,
 * session persistence, api calls, and more.
 * */

/* *
* ハンドラー関数について
* スキルがリクエストを受け取ると、各ハンドラーのcanHandle() 関数がそのハンドラーでリクエストに対応できるかを判断します。
* canHandle() 関数 -> ハンドラーが応答するリクエストを定義します（trueを返すことで、この処理を実行できる）
* handle() 関数 -> ユーザーに応答する内容を定義します
* const Alexa = require('ask-sdk-core');
* const persistenceAdapter = require('ask-sdk-s3-persistence-adapter');
* */

const LaunchRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
    },
    handle(handlerInput) {
        //  起動時の挨拶
        const speakOutput = 'こんにちは、目標リマインダーへようこそ。目標達成希望日時を教えてください。';
        // ユーザーが答えなかった場合に、もう一度たずねる
        const repromptOutput = '私の目標達成日時は2020年10月1日です、あなたの目標達成希望日時を教えてください。';

        
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(repromptOutput)
            .getResponse();
    }
};

const HasDreamdayLaunchRequestHandler = {
    // canHandle()関数はユーザーの目標達成日時がAmazon S3に保存されているかどうかをチェックします
    // 保存されていれば、ハンドラーはSDKに続行が可能である（ユーザーの誕生日が保存されているため、次の処理に進めると知らせます。
    // 保存されていない場合はLaunchRequestHandlerを呼び、目標達成日時の取得を行う
    canHandle(handlerInput) {

        const attributesManager = handlerInput.attributesManager;
        const sessionAttributes = attributesManager.getSessionAttributes() || {};

        const year = sessionAttributes.hasOwnProperty('year') ? sessionAttributes.year : 0;
        const month = sessionAttributes.hasOwnProperty('month') ? sessionAttributes.month : 0;
        const day = sessionAttributes.hasOwnProperty('day') ? sessionAttributes.day : 0;

        return handlerInput.requestEnvelope.request.type === 'LaunchRequest' && year && month && day;

    },
    // handle()関数は、「おかえりなさい。Y歳の誕生日まであとX日です。」と言うようAlexaに指示します。
    // APIを使ってタイムゾーンを取得するため、応答を取得するまでに時間がかかる可能性がるので非同期処理にする
    async handle(handlerInput) {
        //  ServiceClientファクトリーを作成
        const serviceClientFactory = handlerInput.serviceClientFactory;
        //  デバイスIDを取得
        const deviceId = handlerInput.requestEnvelope.context.System.device.deviceId;

        const attributesManager = handlerInput.attributesManager;
        const sessionAttributes = attributesManager.getSessionAttributes() || {};

        const year = sessionAttributes.hasOwnProperty('year') ? sessionAttributes.year : 0;
        const month = sessionAttributes.hasOwnProperty('month') ? sessionAttributes.month : 0;
        const day = sessionAttributes.hasOwnProperty('day') ? sessionAttributes.day : 0;
        

        // タイムゾーン取得、タイムゾーン取得失敗の時はエラーログ吐く
        let userTimeZone;
        try {
            const upsServiceClient = serviceClientFactory.getUpsServiceClient();
            userTimeZone = await upsServiceClient.getSystemTimeZone(deviceId);
        } catch (error) {
            if (error.name !== 'ServiceError') {
                return handlerInput.responseBuilder.speak("サービスへの接続がうまく行きませんでした。").getResponse();
            }
            console.log('error', error.message);
        }
        
        // 現在の日付と時刻を取得します
        const currentDateTime = new Date(new Date().toLocaleString("ja-JP", {timeZone: userTimeZone}));
        // 日数計算の結果に影響するため、日付から時刻を取り除きます
        const currentDate = new Date(currentDateTime.getFullYear(), currentDateTime.getMonth(), currentDateTime.getDate());
        // 現在年度を取得
        const currentYear = currentDate.getFullYear();
        
        // 目標達成日時の取得
        // todo: 変数名変更
        let nextBirthday = Date.parse(`${month} ${day}, ${currentYear}`);

        // 目標達成日時が今年でなければ何年先かを求める
        if (currentDate.getTime() > nextBirthday) {
            const termDay = (currentDate.getTime() - nextBirthday) / 86400000;
            if (termDay > 365) {
            const termYear = Math.floor(termDay / 365)
            nextBirthday = Date.parse(`${month} ${day}, ${currentYear + termYear}`);   
            }
        }
        
        // 8640000(1日をミリ秒に換算したもの)
        const oneDay = 24*60*60*1000;
        
        // 目標達成日時の場合はおめでとうと発生する
        let speechText = `本日が目標達成日時になります。目標達成おめでとうございます。`;
        
        if (currentDate.getTime() !== nextBirthday) {
            const diffDays = Math.round(Math.abs((currentDate.getTime() - nextBirthday)/oneDay));
            speechText = `おかえりなさい、次の目標まで${currentYear - year}年、残り${diffDays}日です。目標達成に向けて本日も頑張りましょう`
        }
        
        return handlerInput.responseBuilder
            .speak(speechText)
            .getResponse();
    }
};

const CaptureGoalIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'CaptureGoalIntent';
    },
    // 非同期処理で必要情報をs3保存
    async handle(handlerInput) {
        const year = handlerInput.requestEnvelope.request.intent.slots.year.value;
        const month = handlerInput.requestEnvelope.request.intent.slots.month.value;
        const day = handlerInput.requestEnvelope.request.intent.slots.day.value
        
        // ユーザーの目標達成日時を保存する
        const attributesManager = handlerInput.attributesManager;
        
        // Amazon S3に値を保存する項目設定
        const dreamDayAttributes = {
            "year" : year,
            "month" : month,
            "day" : day
        };
        
        // ユーザーの情報がAmazon S3に送信されるまで実行完了を待つ
        attributesManager.setPersistentAttributes(dreamDayAttributes);
        await attributesManager.savePersistentAttributes();
        
        
        const speakOutput = `ありがとうございます。目標達成日時は${year}年${month}月${day}日ですね。`;

        return handlerInput.responseBuilder
            .speak(speakOutput)
            //.reprompt('add a reprompt if you want to keep the session open for the user to respond')
            .getResponse();
    }
};

// ユーザ発話：「ヘルプ」と発した時の処理
const HelpIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.HelpIntent';
    },
    handle(handlerInput) {
        const speakOutput = 'あなたの目標達成日時を教えてくれると私が目標達成日時までの残り時間を計算します。あなたの目標達成希望日時を教えてください。';

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};

// ユーザ発話：「キャンセル」と発した時の処理
const CancelAndStopIntentHandler = {
    
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && (Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.CancelIntent'
                || Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.StopIntent');
    },
    
    handle(handlerInput) {
        const speakOutput = 'さようなら〜';

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .getResponse();
    }
};
/* *
 * FallbackIntent triggers when a customer says something that doesn’t map to any intents in your skill
 * It must also be defined in the language model (if the locale supports it)
 * This handler can be safely added but will be ingnored in locales that do not support it yet 
 * */
const FallbackIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.FallbackIntent';
    },
    handle(handlerInput) {
        const speakOutput = 'Sorry, I don\'t know about that. Please try again.';

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};

// ユーザ発話：「終了」と発した時の処理
const SessionEndedRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'SessionEndedRequest';
    },
    handle(handlerInput) {
        console.log(`~~~~ Session ended: ${JSON.stringify(handlerInput.requestEnvelope)}`);
        // Any cleanup logic goes here.
        return handlerInput.responseBuilder.getResponse(); // notice we send an empty response
    }
};

// ユーザ発話：「＜カスタムインテントのサンプル発話（デバッグ用）＞」
const IntentReflectorHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest';
    },
    handle(handlerInput) {
        const intentName = Alexa.getIntentName(handlerInput.requestEnvelope);
        const speakOutput = `You just triggered ${intentName}`;

        return handlerInput.responseBuilder
            .speak(speakOutput)
            //.reprompt('add a reprompt if you want to keep the session open for the user to respond')
            .getResponse();
    }
};

// エラーハンドラ
const ErrorHandler = {
    canHandle() {
        return true;
    },
    handle(handlerInput, error) {
        const speakOutput = 'すいません、ちょっと何言ってるかわからないです、もう一回話しかけてください';
        console.log(`~~~~ Error handled: ${JSON.stringify(error)}`);

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};

// S3保存したデータを読み込む
// Alexaがユーザーに目標日時をたずねる前にAmazon S3に保存したデータを読み込む
const LoadDreamdayInterceptor = {
    async process(handlerInput) {
        const attributesManager = handlerInput.attributesManager;
        const sessionAttributes = await attributesManager.getPersistentAttributes() || {};

        const year = sessionAttributes.hasOwnProperty('year') ? sessionAttributes.year : 0;
        const month = sessionAttributes.hasOwnProperty('month') ? sessionAttributes.month : 0;
        const day = sessionAttributes.hasOwnProperty('day') ? sessionAttributes.day : 0;

        // データが存在-> s3に保存されている日時を取得してセットする
        if (year && month && day) {
            attributesManager.setSessionAttributes(sessionAttributes);
        }
    }
};

/**
 * This handler acts as the entry point for your skill, routing all request and response
 * payloads to the handlers above. Make sure any new handlers or interceptors you've
 * defined are included below. The order matters - they're processed top to bottom 
 * */
exports.handler = Alexa.SkillBuilders.custom()
    .withApiClient(new Alexa.DefaultApiClient())
    // Amazon S3にデータを保存、読み込む設定
    .withPersistenceAdapter(
        new persistenceAdapter.S3PersistenceAdapter({bucketName:process.env.S3_PERSISTENCE_BUCKET})
    )
    // 関数を呼び出す順に羅列する
    .addRequestHandlers(
        HasDreamdayLaunchRequestHandler,
        LaunchRequestHandler,
        CaptureGoalIntentHandler,
        HelpIntentHandler,
        CancelAndStopIntentHandler,
        FallbackIntentHandler,
        SessionEndedRequestHandler,
        IntentReflectorHandler)
    // インターセプターを登録するコードを追加して、SDKにその存在を知らせる
    .addRequestInterceptors(
        LoadDreamdayInterceptor
    )
    // エラーハンドリング
    .addErrorHandlers(
        ErrorHandler)
    .withCustomUserAgent('sample/hello-world/v1.2')
    .lambda();
```

``` package.json
{
  "name": "dream-time",
  "version": "1.2.0",
  "description": "alexa utility for quickly building skills",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Amazon Alexa",
  "license": "Apache License",
  "dependencies": {
    "ask-sdk-core": "^2.7.0",
    "ask-sdk-model": "^1.19.0",
    "aws-sdk": "^2.326.0",
    "ask-sdk-s3-persistence-adapter": "^2.0.0"
  }
}
```


## STEP5 「インテントとスロットを使って情報を取得する」
ユーザーがAlexaの質問にどう答えるかを解釈するインテントを作成します。

インテントとは、ユーザーの音声によるリクエストを満たすアクションのことです。

インテントでは、スロットという引数を任意で使用することもできます。

### インテント追加方法
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/92cd2e7b-548d-1fc3-af61-a8af8ca7accc.png)

カスタムインテントを作成を選択し、インテント名としてCaptureGoalIntentを入力し、カスタムインテントを作成をクリックすると新規でインテントを作成できます。

CaptureGoalIntentはindex.jsで定義したインテント名になります。（これは自分で定義したカスタムインテントなので追加する必要があります）

![スクリーンショット 2021-03-13 14.25.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/844f4696-f74f-5b35-6376-baa06b05ae10.png)

### スロット作成方法
次にスロットを作成しましょう。

今回の目標達成日時では、年、月、日という3つの重要な情報を収集します。

これらの情報をスロットと呼びます。Alexaにどの単語がスロットで、どのタイプのスロットなのかを知らせる必要があります。

```
私は2021年11月7日までに目標を達成します
```

人によって、動的に変更される箇所を波括弧（{ }）で囲んで適切な変数名に変更します。

```
私は{year} 年{month}月{day}日までに目標を達成します
```
![スクリーンショット 2021-03-13 14.31.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/17f23880-88c8-2ff6-7bf1-125b2abdfc90.png)

スロットタイプドロップダウンメニューから、各スロットにスロットタイプを割り当てます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/e5266923-567d-3b1b-258b-b5d70bab144c.png)

必須のスロットには必須の設定をしましょう。

スロットに右側にある「ダイアログを編集」をクリックし、下記のように必須のチェックボックスをONにします。

Alexaの音声プロンプトにはユーザーがmonthスロットの値を提供しなかった場合にAlexaが言うテキストを入力します。

ユーザーの発音にユーザーが想定する発話を入力します。

![スクリーンショット 2021-03-13 14.32.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/605dc504-e497-992e-afaf-275222b86f69.png)


## STEP6 「テストする」
最後の実際にスキルが正常に動くかテストしてみましょう。

テスト タブをクリックします。テストシミュレーターが開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/7ed15ce1-6220-0380-feae-dceeb39140e2.png)

するとAlexaシュミレータが表示されるので、サンプル発話を入力していきましょう。（赤枠の箇所に入力もしくは発話でinputします）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/fef5671b-beb1-9483-8cdb-c988e3705291.png" width=50%>

```
1. まず呼び出し名の「目標リマインダー」と発話する
2. アレクサスキルが挨拶と、質問をしてくる
3. 目標日時を発話する
4. アレクサスキルが目標達成日時を記憶する。
```

次に再度、アクレさを起動させたときに以前に記憶させた日時を記憶しており、現在日時から目標達成日時までの残り期間を自動計算してレスポンス返却してくれるかを確認します。

もう一度、アレクサを呼び出すと目標達成日時までの残り期間を自動計算してレスポンス返却成功です。

![スクリーンショット 2021-03-13 14.55.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/43b34b67-44dc-fe61-12d0-4594fd268647.png)


## STEP7 「スキル公開」
ユーザーへの公開前にいくつかのステップを実施する必要があります。まず、Alexa開発者コンソールの公開タブで、必要な情報を入力します。

### スキルのプレビュー設定
Alexaスキルストアにスキルがどう表示されるかに関する情報を入力します。（下記図参照）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/e2a57eab-b331-5845-b4f5-6b1db8744616.png)

### プライバシーとコンプライアンス設定
プライバシーとコンプライアンス設定を行います（下記図参照）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/95ec36ae-0bd5-790f-bf92-bced90de5bef.png)


### 公開範囲
Alexaスキルをどの範囲まで公開するかの設定を行います（下記図参照）
![スクリーンショット 2021-03-14 17.08.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/e27f1ab1-e62d-cdd7-3560-c6a16861f240.png)



## STEP8 「スキルの認定と公開」
公開範囲の設定が終われば、実際にスキルを申請してみましょう。

申請が完了したた下記図のようにステータスが申請中になります。

結果は数日後にメールにて連絡がきます。
![スクリーンショット 2021-03-20 21.45.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/51940976-40f9-a015-8840-10a9d276e79b.png)


### 申請に落ちた場合
審査に落ちた場合はAmazonより審査に落ちた理由をめちゃくちゃ丁寧に教えてくれるメールを送ってくれます。

下記の写真は自分が審査に落ちたときに送られてきたメールになります。（すごく丁寧に落ちた理由を教えてくれました）

指摘箇所を直し再審査可能なので、修正でき次第また審査に応募しましょう。


![スクリーンショット 2021-03-20 21.48.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/ba1baf98-02ff-b529-df35-bd66c4f834df.png)

### 申請に合格した場合
スキルのステータスや、認証審査プロセスに関する最新情報は、認定＞申請から確認できます。

申請に合格すると、ステータスが「認定済み」に変更されます。

![スクリーンショット 2021-03-23 12.26.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/e66a74d6-394f-30eb-3ecb-26c3b2e02f4e.png)

また、スキルが公開される時期の目安がEメールで通知されます。（公開時期を後から設定にした場合は公開時間を設定してくださいと表記される）
![スクリーンショット 2021-03-23 12.29.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286094/fca26efa-00b7-dfc9-f590-e0fa135f746c.png)


## まとめ
今回はAWSアレクサの開発から公開までの流れをご紹介しました。

普段、利用しているAIスピーカーが裏側でどのように開発、運用されているかの勉強になりました。

興味ある方はぜひ開発に挑戦してみてください。