Alexaスキルからリマインダーを設定する方法とそのプラクティス

はじめに

こんにちは! エムティーアイでエンジニアをしている葛馬です!

日本時間の 2018年11月2日 に Alexa Reminders API が公開され, スキルからリマインダーを設定することができるようになりました. Alexa から話しかける機能が欲しいと思っていた開発者にとっては垂涎ものの機能ですよね.

なんと! 弊社ではグローバルリリースに合わせて, Reminders API を使った 「CARADA 声でおくすり記録」 というスキルをリリースしました!
Amazonさんの開発者ブログ でも取り上げていただいています!

今回は実際の開発現場で得た知見を共有できればと思います!

開発環境

  • node : v8.10.0
  • ask-sdk : v2.270.1
  • moment : v2.22.2

基本的な使い方とAlexaのポリシー

事前準備

まず, アレクサ開発者コンソールの「ビルド」タブから「アクセス権限」を選択し, 「リマインダー」のトグルをオンにします.

コンソール画面でのリマインダー権限有効化画像

次に, Alexa アプリからスキルを有効化すると同時にリマインダーへのアクセス権利も付与します. この時, ユーザーが権限を与えなかった場合のエラー処理を考慮する必要があります(後述)

アレクサアプリでのリマインダー権限有効化の画像

これで準備完了です!

簡単なリマインダーの設定

次は, リマインダーを設定するために以下のようなハンドラーを準備します.

const https = require('https');
const urlParse = require('url').parse;

// JavaScript Library [https://momentjs.com]
const moment = require('moment');

// リマインダーを設定するAPI
const SetReminderHandler = {
    canHandle() {
        return true;
    },
    async handle(handlerInput) {
        const remindTime = moment({ hours: 8 }).add(1, 'days');

        const timeFormat = 'YYYY-MM-DDTHH:mm:ss.SSS';

        const requestBody = {
            requestTime: moment().format(timeFormat),
            trigger: {
                type: 'SCHEDULED_ABSOLUTE',
                scheduledTime: remindTime.format(timeFormat),
                timeZoneId: 'Asia/Tokyo',
                recurrence: { freq: 'DAILY' },
            },
            alertInfo: {
                spokenInfo: {
                    content: [ {
                        locale: 'ja-JP',
                        text: 'おはようございます',
                    } ],
                },
            },
            pushNotification: { status: 'ENABLED' },
        };

        const system = handlerInput.requestEnvelope.context.System;

        const request = {
            hostname: urlParse(system.apiEndpoint).hostname,
            path: '/v1/alerts/reminders',
            method: 'POST',
            auth: `Bearer ${ system.apiAccessToken }`,
            body: JSON.stringify(requestBody),
        };

        // Alexa Reminders API 呼び出し
        await callAPI(request);

        return handlerInput.responseBuilder
            .speak('リマインダーを設定しました')
            .withShouldEndSession(true)
            .getResponse();
    },
};

// API を呼び出す関数
// 今回は簡略化のためにエラー処理は省いています
function callAPI(request) {
    const clientOptions = {
        hostname: request.hostname,
        path: request.path,
        method: request.method,
        headers: {
            'Content-Type': 'application/json',
            Authorization: request.auth,
        },
    };

    return new Promise(resolve => {
        const clientRequest = https.request((clientOptions), response => {
            const chunks = [];
            response.on('data', chunk => {
                chunks.push(chunk);
            });
            response.on('end', () => {
                const parsedObject = JSON.parse(chunks.join(''));
                resolve(parsedObject);
            });
        });

        clientRequest.end(request.body);
    });
}

このハンドラーは,「次の日から毎日朝8時に Alexa に『おはようございます』と言わせるリマインダー」を設定します.
これによって毎日心地よい朝を迎えることができる素晴らしいプログラムです.

「私は朝7時に起きるんだ!8時では遅い!」という方は

const remindTime = moment({ hours: 8 });

87 に変更してみてください.
幸せな朝を迎えられること間違いなしです.

ちなみに, requestBody は Alexa Reminders API に POST する際のリクエストボディです.
詳細が気になる方は https://developer.amazon.com/ja/docs/smapi/alexa-reminders-api-reference.html のドキュメントを参照してみてください.

リマインダーAPIの注意点

先ほどのコードは注意すべき点が 2つ あります.

  1. タイムゾーンの指定
  2. APIトークン と APIエンドポイント

の 2つ です.

ひとつめは, タイムゾーンを指定したいときは scheduledTimeの末尾 ではなく timeZoneId に記述する必要があるということです.
scheduledTimeの末尾に書いてしまうと INVALID_TRIGGER_SCHEDULED_TIME_FORMAT としてレスポンスが返ってきてしまいます.

moment.js の format や JavaScript の Date の toISOString は標準で末尾にタイムゾーンをつけてしまうので注意してください.

// 例
moment().format() // 2018-12-05T16:52:19+09:00
new Date().toISOString() // 2018-12-05T07:52:19.523Z

timeZoneId の有無による違いは https://developer.amazon.com/ja/docs/smapi/alexa-reminders-api-reference.html#timezones-work をご覧ください

ふたつめは, APIトークン と APIエンドポイントの取得方法です.
APIトークンの取得方法は 概要ドキュメント にあるように取得すれば問題ありません.

しかし, APIエンドポイントは APIドキュメント にあるように https://api.amazonalexa.com 固定ではありません.
端末や地域によって変わるのでリクエストから動的に取る様にしてください.

申請を通すために知るべきAlexaのポリシー

世の中に Alexa スキルを公開するにはスキルの審査をパスする必要があります.
Alexa Reminders API を利用したスキルの場合, 以下の条件に従う必要があります.

  1. ユーザーからリマインダー設定について承諾を得ること
  2. どういう意図いつ そのリマインダーを設定するのかユーザーへ明示すること
  3. リマインダーの権限が与えられていない時に, ユーザーにそのことを伝え Permission のカードを出すこと

これらは Alexa のポリシーに関わることなので意識してスキルの開発を行う必要があります. (もちろん, これらの条件に従ったからといって必ず通るわけではありません)

実際に審査を通すには

では実際にポリシーに従ったハンドラーはどういったものなのしょうか?
先ほどのプログラムを改良してみましょう!

ユーザーに尋ねる

まず, リマインダーを設定することにユーザーから了承を得る必要があります.
ユーザーに「リマインダーを設定してもいいか」を質問し, 応答を待つハンドラーは以下の様になります.

const questionHandler = {
    canHandle() {
        return true;
    },
    handle(handlerInput) {
        return handlerInput.responseBuilder
            .speak('リマインダーを設定してもよろしいですか?')
            .reprompt('「はい」か「いいえ」でお答えください')
            .getResponse();
    },
};

ユーザーの応答を待つ会話モードにするために handlerInput.responseBuilder に対して reprompt('ユーザーの反応がなかった時に話す文言') を実行します (reprompt メソッドを実行すると自動的に会話モードになります).

この時, ユーザーの応答を待つ必要があるため, withShouldEndSession(true) メソッドを実行してセッションを終了してしまわないようにしましょう.

ユーザーから許可を得る

次に「リマインダーを設定してもよろしいですか?」という質問に対してユーザーが「はい」や「いいえ」と応答した時のハンドラーを作成します.

const https = require('https');
const urlParse = require('url').parse;

// JavaScript Library [https://momentjs.com/docs/]
const moment = require('moment');

const YesIntentHandler = {
    canHandle() {
        const request = handlerInput.requestEnvelope.request;
        return request.type === 'IntentRequest'
            && request.intent.name === 'AMAZON.YesIntent';
    },
    handle(handlerInput) {
        // リマインダー設定用のロジック (SetReminderHandlerと同様のため省略)
    },
};

const NoIntentHandler = {
    canHandle() {
        const request = handlerInput.requestEnvelope.request;
        return request.type === 'IntentRequest'
            && request.intent.name === 'AMAZON.NoIntent';
    },
    handle(handlerInput) {
        return handlerInput.responseBuilder
            .speak('リマインダーを設定しませんでした')
            .withShouldEndSession(true)
            .getResponse();
    },
};

AMAZON.YesIntentAMAZON.NoIntent が存在しない場合は下の画像の場所からインテントを追加してください.

標準インテント追加方法の画像

これでユーザーから許可を得てリマインダーを設定することができます.
ただし, このコードには

  • 許可を得る処理を追加していくと YesIntentHandler が巨大になる
  • ワンショットでの呼び出し(「Alexa, 〇〇で△△して」)と会話モードでの呼び出しでコードがクローンしてしまう可能性がある

という問題があります.

これらの問題を解決するために, セッションの利用とハンドラーの分割を行います.
セッションは以下の様に扱えます.

// セッションマネージャーの取得
const sessionAttributesManager = handlerInput.attributesManager;

// セッション情報の取得
const sessionAttributes = sessionAttributesManager.getSessionAttributes();

// セッション情報の status(任意の名前) に現在のステータスを書き込む
sessionAttributes.status = 'ASK_IF_SET_REMINDER';

// セッション情報を更新する
sessionAttributesManager.setSessionAttributes(sessionAttributes);

セッションの利用とハンドラーの分割

set_reminder_intent_handler.js
// [file] set_reminder_intent_handler.js
const https = require('https');
const urlParse = require('url').parse;

// JavaScript Library [https://momentjs.com/docs/]
const moment = require('moment');

const SetReminderIntentHandler = {
    canHandle(handlerInput) {
        // 自分で設定したカスタムインテント
        const request = handlerInput.requestEnvelope.request;
        return request.type === 'IntentRequest'
            && request.intent.name === 'SetReminderIntent';
    },
    async handle(handlerInput) {
        // セッションマネージャーの取得
        const sessionAttributesManager = handlerInput.attributesManager;
        // セッション情報の取得
        const sessionAttributes = sessionAttributesManager.getSessionAttributes();

        const intentName = handlerInput.requestEnvelope.request.intent.name;
        if (intentName === 'AMAZON.YesIntent') {
            const remindTime = moment({ hours: 8 }).add(1, 'days');

            const timeFormat = 'YYYY-MM-DDTHH:mm:ss.SSS';

            const requestBody = {
                requestTime: moment().format(timeFormat),
                trigger: {
                    type: 'SCHEDULED_ABSOLUTE',
                    scheduledTime: remindTime.format(timeFormat),
                    timeZoneId: 'Asia/Tokyo',
                    recurrence: { freq: 'DAILY' },
                },
                alertInfo: {
                    spokenInfo: {
                        content: [ {
                            locale: 'ja-JP',
                            text: 'おはようございます',
                        } ],
                    },
                },
                pushNotification: { status: 'ENABLED' },
            };

            const system = handlerInput.requestEnvelope.context.System;

            const request = {
                hostname: urlParse(system.apiEndpoint).hostname,
                path: '/v1/alerts/reminders',
                method: 'POST',
                auth: `Bearer ${ system.apiAccessToken }`,
                body: JSON.stringify(requestBody),
            };

            // Alexa Reminders API 呼び出し
            await callAPI(request);

            return handlerInput.responseBuilder
                .speak('リマインダーを設定しました')
                .withShouldEndSession(true)
                .getResponse();
        }

        // セッション情報の status(任意の名前) に現在のステータスを書き込む
        sessionAttributes.status = 'ASK_IF_SET_REMINDER';

        // セッション情報を更新する
        sessionAttributesManager.setSessionAttributes(sessionAttributes);

        // YesIntent から呼び出されなかった場合の処理
        return handlerInput.responseBuilder
            .speak('リマインダーを設定してもよろしいですか?')
            .reprompt('「はい」か「いいえ」でお答えください')
            .getResponse();
    },
};

function callAPI(request) {
    const clientOptions = {
        hostname: request.hostname,
        path: request.path,
        method: request.method,
        headers: {
            'Content-Type': 'application/json',
            Authorization: request.auth,
        },
    };

    return new Promise(resolve => {
        const clientRequest = https.request((clientOptions), response => {
            const chunks = [];
            response.on('data', chunk => {
                chunks.push(chunk);
            });
            response.on('end', () => {
                const parsedObject = JSON.parse(chunks.join(''));
                resolve(parsedObject);
            });
        });

        clientRequest.end(request.body);
    });
}


module.exports = SetReminderIntentHandler;
yes_intent_handler.js
// [file] yes_intent_handler.js
const SetReminderIntentHandler = require('./set_reminder_intent_handler');

const YesIntentHandler = {
    canHandle(handlerInput) {
        const request = handlerInput.requestEnvelope.request;
        return request.type === 'IntentRequest'
            && request.intent.name === 'AMAZON.YesIntent';
    },
    async handle(handlerInput) {
        const attributesManager = handlerInput.attributesManager;
        const sessionAttributes = attributesManager.getSessionAttributes();

        if (sessionAttributes.status === 'ASK_IF_SET_REMINDER') {
            return (await SetReminderIntentHandler.handle(handlerInput));
        }

        // 会話モードではなかった場合
        return handlerInput.responseBuilder
            .speak('よく分かりませんでした')
            .withShouldEndSession(true)
            .getResponse();
    },
};

module.exports = YesIntentHandler;
no_intent_handler.js
// [file] no_intent_handler.js
const NoIntentHandler = {
    canHandle(handlerInput) {
        const request = handlerInput.requestEnvelope.request;
        return request.type === 'IntentRequest'
            && request.intent.name === 'AMAZON.NoIntent';
    },
    async handle(handlerInput) {
        const attributesManager = handlerInput.attributesManager;
        const sessionAttributes = attributesManager.getSessionAttributes();

        if (sessionAttributes.status === 'ASK_IF_SET_REMINDER') {
            return handlerInput.responseBuilder
                .speak('リマインダーを設定しませんでした')
                .withShouldEndSession(true)
                .getResponse();
        }

        // 会話モードではなかった場合
        return handlerInput.responseBuilder
            .speak('よく分かりませんでした')
            .withShouldEndSession(true)
            .getResponse();
    },
};

module.exports = NoIntentHandler;

ここでは, 各ハンドラーを別ファイルとして定義しています. 分割したファイルとセッションの値を使うことで,

  1. リマインダーに関する関連処理の集約
  2. コードクローンの削除

ができました.

エラー処理

先程までのコードでは Alexa のポリシーの

  • リマインダーの権限が与えられていない時に, そのことを伝え Permission のカードを出すこと

を満たせません.

そこで, 最後にハンドラーにエラー処理を追加してみましょう.
リマインダーへの権限が与えられていない場合, Alexa Reminders API は code: UNAUTHORIZED でレスポンスを返します.

それをキャッチしてエラーとして扱ってあげましょう.
注意 : alexa の SkillBuilders.addErrorHandlers にエラーハンドラーを追加しても 会話モード時にはハンドリングされません.

エラーハンドラーを以下のように宣言します.

const ErrorHandler = {
    canHandle() {
        return true;
    },
    handle(handlerInput, error) {
        const errorMessages = {
            'UNAUTHORIZED': 'リマインダーへのアクセス権がありません',
            'UNEXPECTED': '予期せぬエラーが発生しました',
        };

        let response = handlerInput.responseBuilder;

        switch (error.message) {
            case 'UNAUTHORIZED':
                // リマインダーの権限を求めるカードを出力する
                response = response.withAskForPermissionsConsentCard(
                    ['alexa::alerts:reminders:skill:readwrite']
                );
                break;
            default:
                response = response.withStandardCard(
                    errorMessages[error.message],
                    errorMessages[error.message],
                );
                break;
        }

        return response
            .speak(errorMessages[error.message])
            .withShouldEndSession(true)
            .getResponse();
    }
};

module.exports = ErrorHandler;

現状のコードでは

  • リマインダーの権限がない時
  • 予期せぬエラーが発生した時

にのみ対応しています.
もしあなたが他のエラーケースにも対応したい場合は, case文 にエラーケースを追加するだけで実装することができます.

エラーハンドラーが出来たので, Alexa Reminders API から UNAUTHORIZED が返ってきた時にパーミッションがないと判断して権限を求めるカードを出力するように set_reminder_intent_handler.js を変更しましょう.

set_reminder_intent_handler.js
// [file] set_reminder_intent_handler.js
const https = require('https');
const urlParse = require('url').parse;

// JavaScript Library [https://momentjs.com/docs/]
const moment = require('moment');

// エラー処理用のハンドラー
const ErrorHandler = require('./error_handler');

const SetReminderIntentHandler = {
    canHandle(handlerInput) {
        // 自分で設定したカスタムインテント
        const request = handlerInput.requestEnvelope.request;
        return request.type === 'IntentRequest' &&
            request.intent.name === 'SetReminderIntent';
    },
    async handle(handlerInput) {
        try {
            // セッションマネージャーの取得
            const sessionAttributesManager = handlerInput.attributesManager;

            // セッション情報の取得
            const sessionAttributes = sessionAttributesManager.getSessionAttributes();

            const intentName = handlerInput.requestEnvelope.request.intent.name;
            if (intentName === 'AMAZON.YesIntent') {
                const remindTime = moment({ hours: 8 }).add(1, 'days');

                const timeFormat = 'YYYY-MM-DDTHH:mm:ss.SSS';

                const requestBody = {
                    requestTime: moment().format(timeFormat),
                    trigger: {
                        type: 'SCHEDULED_ABSOLUTE',
                        scheduledTime: remindTime.format(timeFormat),
                        timeZoneId: 'Asia/Tokyo',
                        recurrence: { freq: 'DAILY' },
                    },
                    alertInfo: {
                        spokenInfo: {
                            content: [ {
                                locale: 'ja-JP',
                                text: 'おはようございます',
                            } ],
                        },
                    },
                    pushNotification: { status: 'ENABLED' },
                };

                const system = handlerInput.requestEnvelope.context.System;

                const request = {
                    hostname: urlParse(system.apiEndpoint).hostname,
                    path: '/v1/alerts/reminders',
                    method: 'POST',
                    auth: `Bearer ${ system.apiAccessToken }`,
                    body: JSON.stringify(requestBody),
                };

                // Alexa Reminders API 呼び出し
                const result = await callAPI(request);

                // リマインダーへのアクセス権がない場合のエラー処理
                if (result.code === 'UNAUTHORIZED') {
                    throw new Error('UNAUTHORIZED');
                }

                return handlerInput.responseBuilder
                    .speak('リマインダーを設定しました')
                    .withShouldEndSession(true)
                    .getResponse();
            }

            // セッション情報の status(任意の名前) に現在のステータスを書き込む
            sessionAttributes.status = 'ASK_IF_SET_REMINDER';

            // セッション情報を更新する
            sessionAttributesManager.setSessionAttributes(sessionAttributes);

            return handlerInput.responseBuilder
                .speak('リマインダーを設定してもよろしいですか?')
                .reprompt('「はい」か「いいえ」でお答えください')
                .getResponse();
        } catch (error) {
            return ErrorHandler.handle(handlerInput, error);
        }
    },
};

function callAPI(request) {
    const clientOptions = {
        hostname: request.hostname,
        path: request.path,
        method: request.method,
        headers: {
            'Content-Type': 'application/json',
            Authorization: request.auth,
        },
    };

    return new Promise(resolve => {
        const clientRequest = https.request((clientOptions), response => {
            const chunks = [];
            response.on('data', chunk => {
                chunks.push(chunk);
            });
            response.on('end', () => {
                const parsedObject = JSON.parse(chunks.join(''));
                resolve(parsedObject);
            });
        });

        clientRequest.end(request.body);
    });
}


module.exports = SetReminderIntentHandler;

try - catch で処理の全文を包んでいるのはあらゆるエラーに対しても応答を返すためです.
Alexa 開発においてエラーをキャッチできずに応答を返してしまうのは望ましくありませんから.

最後に

Alexa Reminders API について書こう書こうと思っている間に 2月 になってしまいました.
多くの方が求めていたであろう機能だけに「もう少し早くこの記事を書き上げるべきだったなぁ」と反省しています.

その肝心の記事のサンプルソースは,

  • なるべく node の標準ライブラリだけに絞り
  • わかりやすく
  • 実用性を失わないこと

を意識して書きました.

この記事が, みなさんの Alexaスキル開発 の助けになれば幸いです!
(いつかセッション外での「Alexa Reminders API 呼び出し」に関する記事とかも書いてみたいです...)