Alexa-hosted スキルからリマインダー API を使う

[Alexa Developer スキルアワード2019 ハッカソン東京 Vol.2](https://alexaskillawards.connpass.com/event/131484/) でリマインダーAPIを使ったスキルを作ったところ、「リマインダーを使ってみたい！」というフィードバックをたくさんいただきました。この記事が皆さんのお役に立てば光栄です！

プロダクトに組み込む前に、ぜひお試しスキルを作って動作を確認してみてください。ハマりポイントがいくつかあります。

# 前提
- Alexa-hostedスキルで作ります。
  - Amazon 開発者アカウントさえあればスキル開発できます。AWS アカウント不要。
  - Web ブラウザーだけで開発できます。Windows PC でもOK。
  - 詳しくは公式ドキュメント [Alexa-hostedスキルを使用してスキルをエンドツーエンドで作成する](https://developer.amazon.com/ja/docs/hosted-skills/build-a-skill-end-to-end-using-an-alexa-hosted-skill.html)
- 指定秒数後にリマインドします。相対時刻を使うやり方（SCHEDULED_RELATIVE）です。
  - 絶対時刻を指定するやり方は、公式ドキュメントの[リマインダーの作成](https://developer.amazon.com/ja/docs/smapi/alexa-reminders-api-reference.html#create-a-reminder)を参照してください。type: "SCHEDULED_ABSOLUTE" のところです。

# 参考情報
- 公式ドキュメント
  - [Alexaリマインダーの概要](https://developer.amazon.com/ja/docs/smapi/alexa-reminders-overview.html)
- 公式リポジトリのサンプルコード（英語）
  - [skill-demo-reminders](https://github.com/alexa/alexa-cookbook/tree/master/feature-demos/skill-demo-reminders)
- Alexa Blogs
  - [Alexaスキル開発トレーニングシリーズ：リマインダーAPIの利用方法](https://developer.amazon.com/ja/blogs/alexa/post/67953f88-d837-48ea-a8b0-ce9316fc4e5b/alexatraining-reminder-api)
  -  上記サンプルコードの解説です。

# 開発するスキル

指定した秒数が経つとリマインドします。

- ユーザー「アレクサ、お試しリマインダーを開いて」
 - Alexa「何秒後にリマインドしますか？」
- ユーザー「10秒後」
 - Alexa「分かりました。10秒後にリマインドします。」
 
10秒たつと、スマホに通知が来ます。実機だと音が鳴り、Alexaがしゃべります。

# 手順

## 開発者コンソール「ビルド」タブでの作業

### アクセス権限で、リマインダーを有効にする

[開発者コンソール](https://developer.amazon.com/alexa/console/ask/)からスキルを選び、上部タブ「ビルド」＞左ペイン「アクセス権限」＞「リマインダー」をチェックします。

### RemindIntent を作る

ReminderIntent を作ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/9c2b7cd5-c46e-8254-4ae1-f50ffe47caeb.png)

インテントスロット sec を作ります。スロットタイプは AMAZON.NUMBER です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/c21979d4-9645-a65d-758b-2a5ab680a895.png)

そして「このインテントには確認が必要ですか？」にチェックを入れます（重要！）
「Alexaのプロンプト」には「{sec}後にリマインドしてもよろしいでしょうか？」と入力します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/7e5ece02-db0b-0ef1-b6b1-4ef28d514577.png)

忘れずに上部のボタン「モデルをビルド」を押しておきましょう。

もし「確認が必要」のチェックを入れ忘れると、「テスト」タブのAlexaシミュレーター、スキルI/OのJSON入力に、以下のエラーが表示されます。

```javascript
	"request": {
		"type": "SessionEndedRequest",
... 
		"error": {
			"type": "INVALID_RESPONSE",
			"message": "The following directives are not supported: DelegateDirective"
		}
	}
```

## 開発者コンソール「コードエディタ」タブでの作業

### package.json

ask-sdk-core を 2.3.0 に上げます。

```javascript:package.json
  "dependencies": {
    "ask-sdk-core": "^2.3.0",
    "ask-sdk-model": "^1.11.1",
...
  }
```

これを忘れると、実行時のログ CloudWatch に以下のようなエラーが表示されます。

```
handlerInput.serviceClientFactory.getReminderManagementServiceClient is not a function
```

### index.js

さぁ、いよいよコーディングです。

- PERMISSIONS定数にリマインダーAPIを入れておく。
- 起動したら「何秒後にリマインドしますか？」と話す。
- RemindIntentHandler を追加する。
- ErrorHandler を変更する。
- export.handler に .withApiClient() を追加する。

```javascript
...
const PERMISSIONS = ['alexa::alerts:reminders:skill:readwrite'];

const LaunchRequestHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
    },
    handle(handlerInput) {
        const speechText = '何秒後にリマインドしますか？';
        return handlerInput.responseBuilder
            .speak(speechText)
            .reprompt(speechText)
            .getResponse();
    }
};

const RemindIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'RemindIntent';
    },
    // handle の前に async 追加
    async handle(handlerInput) {
        const sec = handlerInput.requestEnvelope.request.intent.slots.sec.value;
        const requestEnvelope = handlerInput.requestEnvelope;
        const responseBuilder = handlerInput.responseBuilder;
        const consentToken = requestEnvelope.context.System.apiAccessToken;

        // check for confirmation.  if not confirmed, delegate
        switch (requestEnvelope.request.intent.confirmationStatus) {
        case 'CONFIRMED':
            // intent is confirmed, so continue
            console.log('Alexa confirmed intent, so clear to create reminder');
            break;
        case 'DENIED':
            // intent was explicitly not confirmed, so skip creating the reminder
            console.log('Alexa disconfirmed the intent; not creating reminder');
            return responseBuilder
                .speak(`わかりました。リマインドしません。`)
                .getResponse();
        case 'NONE':
        default:
            console.log('delegate back to Alexa to get confirmation');
            return responseBuilder
                .addDelegateDirective()
                .getResponse();
        }

        if (!consentToken) {
            return responseBuilder
                .speak(`アレクサアプリにカードを送信しました。リマインダーの権限を有効にしてください。`)
                .withAskForPermissionsConsentCard(PERMISSIONS)
                .getResponse();
        }
        try {
            const client = handlerInput.serviceClientFactory.getReminderManagementServiceClient();
            const reminderRequest = {
                trigger: {
                    type: 'SCHEDULED_RELATIVE',  // 指定秒数後にリマインド
                    offsetInSeconds: sec,
                },
                alertInfo: {
                    spokenInfo: {
                        content: [{
                            locale: 'ja-JP', // 日本国の日本語
                            text: 'お時間です', // スマホに通知する文言
                        }],
                    },
                },
                pushNotification: {
                    status: 'ENABLED',
                },
            };
            const reminderResponse = await client.createReminder(reminderRequest);
            console.log(JSON.stringify(reminderResponse));
        } catch (error) {
            if (error.name !== 'ServiceError') {
                console.log(`error: ${error.stack}`);
                const response = responseBuilder.speak(`おっと。エラーが発生しました。`).getResponse();
                return response;
            }
            throw error;
        }
        return responseBuilder
            .speak(`分かりました。${sec}秒後にリマインドします。`)
            .getResponse();
    },
};

// HelpIntentHandler などはそのまま使う。

// ErrorHandler は書き換える
const ErrorHandler = {
  canHandle(handlerInput, error) {
    return error.name === 'ServiceError';
  },
  handle(handlerInput, error) {
    // console.log(`ERROR STATUS: ${error.statusCode}`);
    console.log(`ERROR MESSAGE: ${error.message}`);
    // console.log(`ERROR RESPONSE: ${JSON.stringify(error.response)}`);
    // console.log(`ERROR STACK: ${error.stack}`);
    switch (error.statusCode) {
      case 401:
        return handlerInput.responseBuilder
          .speak(`Alexaアプリのホーム画面で、スキルに権限を付与してください。`)
          .withAskForPermissionsConsentCard(PERMISSIONS)
          .getResponse();
      case 403:
        return handlerInput.responseBuilder
          .speak(`このデバイスではリマインダーを設定することができません。`)
          .getResponse();
      default:
        return handlerInput.responseBuilder
          .speak(`リマインダーの設定でエラーが発生しました。`)
          .getResponse();
    }
  },
};

exports.handler = Alexa.SkillBuilders.custom()
    .addRequestHandlers(
        LaunchRequestHandler,
        RemindIntentHandler,
        HelpIntentHandler,
        CancelAndStopIntentHandler,
        SessionEndedRequestHandler,
        IntentReflectorHandler)
    .addErrorHandlers(
        ErrorHandler)
    // 追加
    .withApiClient(new Alexa.DefaultApiClient())
    .lambda();
```

# うまく動かなかった時のチェックリスト

次のことが正しくできているか、確認してください。

- [ ] 開発コンソールのアクセス権限でリマインダーをチェックしている
- [ ] package.json を修正して、ask-sdk-core のバージョンを上げてある
- [ ] RemindIntent の「このインテントには確認が必要ですか？」をチェックしている
- [ ] アレクサアプリで、リマインダーの権限を有効にしている

# ハッカソンで作ったスキル

自宅で運動したくなるスキルです。毎日決まった時間になると、Alexaがリマインドしてくれます。
@zono_0 さん、動画ありがとうございます！

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr"><a href="https://twitter.com/hashtag/Alexa%E3%82%B9%E3%82%AD%E3%83%AB%E3%82%A2%E3%83%AF%E3%83%BC%E3%83%89?src=hash&amp;ref_src=twsrc%5Etfw">#Alexaスキルアワード</a><br>Alexaスキル 7daysチャレンジ <a href="https://t.co/2AjeQikUu8">pic.twitter.com/2AjeQikUu8</a></p>&mdash; TAKAHIRO NISHIZONO (@zono_0) <a href="https://twitter.com/zono_0/status/1142702291960512512?ref_src=twsrc%5Etfw">June 23, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>