kun432's blog

Alexaなどスマートスピーカーの話題中心に、Voiceflowの日本語情報を発信してます。たまにAWSやkubernetesなど。

〜スマートスピーカーやVoiceflowの記事は右メニューのカテゴリからどうぞ。〜

今更ながらプロアクティブイベントAPIを試してみる①

手を動かして理解しようシリーズ(今作った)です。一度はやってみたいと思っていたプロアクティブイベントAPIによるプッシュ通知を試してみたいと思います。

参考にさせていただくのは「Alexa Deep Dive」の第5章、せーのさんのパートです。

PDFが無料で読めるので、ありがたく拝見させていただきましょう!

目次

プロアクティブイベントAPIの仕組み

プロアクティブイベントAPIは、MQTTを使ったPub/Sub型モデルで成り立っています。Pub/Subモデルは、例えば、メルマガをイメージすると良いと思います。

f:id:kun432:20200801083207p:plain

一般的なメルマガのしくみとして、メルマガ発行者がすべてのメルマガ購読者宛に直接メールを送っていることはなく、メールマガジン配信システムが間に存在している事が多いです。これにより、それぞれの役割が分担されます。

  • メルマガ発行者は、記事を書いて配信システムに送るだけ。
  • メルマガ購読者は、事前にメルマガの購読を申し込んだら、あとは受けるだけ。
  • メルマガ配信システムは、
    • 発行者向けに配信用インタフェースを用意し、発行者からのメルマガメールを購読者に中継する。
    • 購読者向けに購読申込・停止用インタフェースを用意し、該当のメルマガ購読者だけに発行者からのメルマガメールを送信する、購読停止したメルマガ購読者には送らないといった管理も行う。

メルマガ配信システムがメールの配信や購読者管理を行ってくれるので、メルマガ発行者とメルマガ購読者が直接やりとりをすることがなくなり、それぞれ送るだけ・見るだけに集中することができるようになるというのが、Pub/Subのメリットです。他にも色々なメリットはありますが、本筋ではないのでここでは割愛します。

なお、Pub/Subにおける登場人物を以下のように言います。わかりやすいですね。

発行者=Publisher 購読者=Subscriber 中継者=Broker

で、これをAlexaのプロアクティブイベントAPIに置き換えると、こうなります。

f:id:kun432:20200801104442p:plain

実際にはもちょっとややこしい感じです。

  • Pub/Subの関係上は「スキル」がSubscriberになるけど、バックエンド側(Lambda)でメッセージ受けて処理するわけではなく、AlexaのクラウドからEchoデバイスにメッセージが送信される。
  • したがって、バックエンドでやることは、せいぜい、通知が許可されているかどうかをチェックして、許可を促す程度。
  • ただし、スキルのマニフェストを修正して、通知に必要な情報を設定しておく必要がある。これにより、ユーザがそのスキルの通知を許可したタイミングで、スキル(+ユーザのEchoデバイス)がSubscriberになり、通知が飛ぶ。

という感じかなと思います。正しいかどうかはわかりませんが、私のイメージはこういう感じです。

f:id:kun432:20200801113643p:plain

普段「スキル」というときに、フロントエンドである「Alexa Skills Kit」側と、バックエンドで最も一般的な「Lambda」(の両方を含めて言いますが、Proactive Event APIを使った通知はフロントエンド側で完結するような感じで考えればいいのではないかなと思います。ただしバックエンドのエンドポイントは有効なLambda ARNであることが前提なようです。何に使ってるのかはわかりませんが、このあたりもややこしいですね。

スキルの作成

では、実際にやってみましょう。

やり方については、冒頭でご紹介した「Alexa Deep Dive」や、以下のクラメそさんブログでも詳しく書かれているので、その通りにやるだけなんですが、ask-cliがv2になっていたりしますし、自分でも実際に手を動かしてみないとわからないので、やってみます。

まず、Alexa-hostedで「Hello Worldスキル」テンプレートを使ってとりあえずスキルを作って、スキル一覧画面を開きます。今回は「プロアクティブイベントAPIのサンプル」という名前のスキルを作りました。

f:id:kun432:20200801115529p:plain

スキルマニフェストの修正

さきほど記載したとおり、スキルのバックエンド(Alexa-hostedのLambda)を実装する必要はないですが、有効なスキルエンドポイントは必要ですし、プロアクティブイベントAPIを利用するには、ask-cliによるスキルマニフェストの修正が必要になります(開発者コンソールではできない)。

まず、ask-cliの実行に必要なスキルIDを確認します。スキル一覧画面でスキル名の下にある「スキルIDの表示」をクリックして表示される、amzn1.ask.skill.〜で始まる文字列をメモしておきます。

f:id:kun432:20200801115806p:plain

f:id:kun432:20200801120016p:plain

次に、ask-cliを使って、スキルマニフェストを一旦ダウンロードします。ask-cli v1とv2でコマンドやオプションが違うのでご注意ください。

ask-cli v1の場合

$ ask api get-skill -s amzn.ask.skill.xxxxx-xxxx-xxxx > skill.json

ask-cli v2の場合。ステージの指定が必須になっている。

$ ask smapi get-skill-manifest -s amzn.ask.skill.xxxxx-xxxx-xxxx -g development > skill.json

これでスキルマニフェストがskill.jsonとしてダウンロードされました。中身はこんな感じです。

{
  "manifest": {
    "apis": {
      "custom": {
        "endpoint": {
          "uri": "arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX:Release_0"
        },
        "regions": {
          "EU": {
            "endpoint": {
              "uri": "arn:aws:lambda:eu-west-1:XXXXXXXXXXXX:function:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX:Release_0"
            }
          },
          "NA": {
            "endpoint": {
              "uri": "arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX:Release_0"
            }
          },
          "FE": {
            "endpoint": {
              "uri": "arn:aws:lambda:us-west-2:XXXXXXXXXXXX:function:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX:Release_0"
            }
          }
        }
      }
    },
    "manifestVersion": "1.0",
    "publishingInformation": {
      "category": "KNOWLEDGE_AND_TRIVIA",
      "distributionCountries": [],
      "isAvailableWorldwide": true,
      "locales": {
        "ja-JP": {
          "description": "Sample Full Description",
          "examplePhrases": [
            "Alexa open hello world",
            "hello",
            "help"
          ],
          "keywords": [],
          "name": "プロアクティブイベントAPIのテスト",
          "summary": "Sample Short Description"
        }
      },
      "testingInstructions": "Sample Testing Instructions."
    }
  }
}

ここに「パーミッション」と「イベントスキーマ」を追加するようにskill.jsonを修正します。

パーミッション

プロアクティブイベントAPIによる通知にはalexa::devices:all:notifications:write権限の許可が必要になりますので、これを"manifest"の下の改装、"manifestVersion"などと同じ階層に追加します。

{
  "manifest": {
    "apis": {
    ...snip...
    },
    "manifestVersion": "1.0",
    "permissions": [                                       // 追加
      {                                                    // 追加
        "name": "alexa::devices:all:notifications:write"   // 追加
      }                                                    // 追加
    ],                                                     // 追加
    "publishingInformation": {
    ...snip...
  }
}

イベントスキーマ

次にイベントスキーマです。Alexaには、通常の対話によるスキル利用以外の「スキルの外側」で起きたイベント(スキル有効・無効時、スキルに対する権限変更時、アカウントリンクのリンク時・リンク解除時など)を拾って処理を行うことができる「スキルイベント」という機能がありますが、この設定がイベントスキーマです。

プロアクティブイベントAPIもこれを使っているので、スキルマニフェストに以下を設定する必要があります。

  • 通知に使うテンプレート(publications)
  • subscribeするイベント(subscriptions)
  • イベントを送信するエンドポイント(endpoint)

順に見ていきます。

通知に使うテンプレート(publications)

プロアクティブイベントAPIでは、開発者が自由に通知するメッセージの内容を決めれるわけではなく、予め用意されたテンプレートから選ぶようになっていおり、publicationsにはこのテンプレート名を記載します。以下から選択可能です。

  • AMAZON.WeatherAlert.Activated(天気の注意報や警報)
  • AMAZON.SportsEvent.Updated(スポーツの試合速報)
  • AMAZON.MessageAlert.Activated(メッセージのリマインダー)
  • AMAZON.OrderStatus.Updated(注文の最新ステータス)
  • AMAZON.Occasion.Updated(予約の確認)
  • AMAZON.TrashCollectionAlert.Activated(ごみ収集のリマインダー)
  • AMAZON.MediaContent.Available(メディアコンテンツの利用状況通知)
  • AMAZON.SocialGameInvite.Available(ソーシャルゲームの招待の通知)

実際にメッセージ送信する際もこのテンプレートに従って送信する必要がありますので、送信したいメッセージに適したものを使用してください、なんですが、自由度がかなり低いです・・・

各テンプレートの詳細は以下をご覧ください。

今回は「AMAZON.MediaContent.Available(メディアコンテンツの利用状況通知)」を使うこととします。

subscribeするイベント(subscriptions)

どのイベントをスキルに通知するか?を設定します。複数のイベントを設定しておけば、それぞれのイベントに応じてスキルのバックエンド側の処理を変えたり、ということができます。

ただし、プロアクティブイベントAPIの場合は、SKILL_PROACTIVE_SUBSCRIPTION_CHANGEDのみです。

イベントを送信するエンドポイント(endpoint)

イベントをスキルに通知する場合にどのエンドポイントに送るか?を設定します。一般的にはスキルと同じLambdaのARNにしておいて、イベント通知を受けるためのハンドラもスキル内に実装する事が多いと思いますが、外部のシステムにイベントを送って処理をさせた結果をスキルで使う、というようなこともできるということですね。

プロアクティブイベントAPIでは、SKILL_PROACTIVE_SUBSCRIPTION_CHANGED しか受けれないですし、このイベントでなにかやれることもあまりなさそうな気がしますので、基本的にはエンドポイントに指定してあるAWS LambdaのデフォルトARNでよいはずです。

ということで、イベントスキーマも追加しましょう。こんな感じです。

{
  "manifest": {
    "apis": {
    ...snip...
    },
    "manifestVersion": "1.0",
    "permissions": [
      {
        "name": "alexa::devices:all:notifications:write"
      }
    ],
    "events": {                                                                        // 追加
      "publications": [                                                                // 追加
        {                                                                              // 追加
          "eventName": "AMAZON.MediaContent.Available"                                 // 追加
        }                                                                              // 追加
      ],                                                                               // 追加
      "endpoint": {                                                                    // 追加
        "uri": "arn:aws:lambda:us-east-1:XXXXXXXXXXXX:function:XXXXXXXXXXX:Release_0"  // 追加
      },                                                                               // 追加
      "subscriptions": [                                                               // 追加
        {                                                                              // 追加
          "eventName": "SKILL_PROACTIVE_SUBSCRIPTION_CHANGED"                          // 追加
        }                                                                              // 追加
      ]                                                                                // 追加
    },                                                                                 // 追加
    "publishingInformation": {
    ...snip...
  }
}

再度ask-cliを使って編集したskill.jsonをアップロード、スキルマニフェストに反映させます。ここもask-cli v1/v2で異なります。

ask-cli v1の場合。-fで編集したjsonファイルを指定します。

$ ask api update-skill -s amzn.ask.skill.xxxxx-xxxx-xxxx -f skill.json

ask-cli v2の場合。ここちょっとハマりどころです。--manifestで修正後のマニフェストを指定するのですが、--manifest ファイル名ではなく、--manifest "JSON本文"みたいなんですね。なので--manifest "$(cat skill.json)"としてやる必要があります。

$ ask smapi update-skill-manifest -s amzn.ask.skill.xxxxx-xxxx-xxxx -g development --manifest "$(cat skill.json)"

なお、これらのコマンドは非同期なのでコマンド実行時にエラーが出なかったとしても、再度ask api get-skill(v1)/ask smapi get-skill-manifest(v2)で確認しておいてください。また、ask api get-skill(v1)/ask smapi get-skill-status(v2)で実行結果を確認することもできます。以下はv2の場合の例です。

$ ask smapi get-skill-status --skill-id amzn.ask.skill.xxxxx-xxxx-xxxx --resource manifest --profile default
{
  "manifest": {
    "eTag": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "lastUpdateRequest": {
      "status": "SUCCEEDED"
    }
  }
}

これでスキルの権限を設定できるようになりましたので、

  • Alexa開発者コンソールで該当スキルのテストシミュレータで「スキルテストが有効になっているステージ」を「非公開」から「開発中」に変更。

f:id:kun432:20200801152546p:plain

  • Alexaアプリで該当スキルの設定を開いて「Alexaの通知」を許可

f:id:kun432:20200801152256p:plainf:id:kun432:20200801152305p:plain

しておいてください。

はい、スキル側の準備は完了です。次はメッセージの送信側に移ります。

メッセージの送信

ではメッセージの送信です。送信はスキルに関係なく外部からでも行えるので、手元でスクリプトを書いて実行したいと思います。

まず、スクリプトの送信には以下の情報が必要ですので、順次確認して行きます。

  • クライアントID/クライアントシークレット
  • ユーザID(個別送信の場合、一斉送信の場合は不要)

クライアントID/クライアントシークレット

プロアクティブイベントAPIに限らず、AlexaでAPIサービスを使う場合、アクセストークンを取得してからAPIへリクエストを行います。スキルの中で行う場合は、リクエストの中に含まれているapiAccessTokenを使ったり、またはserviceClientFactoryを使えばこのあたりをよしなにやってくれるので、ほとんど意識することがありませんが、外部から実行する場合は、

  1. 事前にクライアント認証を行って(一定時間だけ利用可能な)アクセストークンを取得
  2. 取得したアクセストークンでAPIへの認証を行い、APIへリクエストを送る

という2段階の認証が必要になります。 クライアントID/クライアントシークレットはこのアクセストークンを取得するための認証に必要で、開発者コンソールから確認できます。

開発者コンソールのメニューから、「ツール」→「アクセス権限」と進みます。

f:id:kun432:20200802010500p:plain

アクセス権限の画面の一番下に「Alexaスキルメッセージング」というのがあり、ここで確認ができます。クライアントシークレットは伏せ字になっていますが「表示」をクリックすると表示されます。このクライアントID/クライアントシークレットをメモしておきます。

f:id:kun432:20200801205234p:plain

ユーザID(個別送信の場合、一斉送信の場合は不要)

メッセージの送信は、特定のユーザIDにのみ送ることもできますし、スキルを利用している(+通知を許可している)ユーザ全員に一斉送信することもできます。特定のユーザIDに送る場合は、ログなり永続ストレージなりにユーザIDを記録しておくようにスキル側で実装しておく必要があります。

今回はテストであり、スキル側には特に実装していないので、スキルのリクエストから取得しておきます。テストシミュレータでスキルを起動するとスキルI/Oに表示されますので、これをメモっておきます。

f:id:kun432:20200801211205p:plain

スクリプト

スクリプトは、「Alexa Deep Dive」およびクラメソさんブログにあるものをそのまま使わせていただきます。ただし記載されているスクリプトは、

  • AMAZON.TrashCollectionAlert.Activated(ごみ収集のリマインダー)向けになっている
  • 一斉送信するようになっている

ので、 そののあたりを修正して、push.jsを作りました。

const rp = require('request-promise');

const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;

notify(); 

async function notify() {
    const token = await getToken(clientId, clientSecret);
    console.log(`[DEBUG] access_token: ${token}`);
    await sendEvent(token);
}

async function getToken(clientId, clientSecret) {
    const uri = 'https://api.amazon.com/auth/o2/token'

    let body = 'grant_type=client_credentials';
    body += '&client_id=' + clientId;
    body += '&client_secret=' + clientSecret;
    body += '&scope=alexa::proactive_events';

    const options = {
        method: 'POST',
        uri: uri,
        timeout: 30 * 1000,
        body: body,
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    };
    const data = await rp(options);
    return JSON.parse(data).access_token;
}

async function sendEvent(token) {

    const body = JSON.stringify(mediaContentAvailableEvent());
    const uri = 'https://api.fe.amazonalexa.com/v1/proactiveEvents/stages/development'
    //const uri = 'https://api.fe.amazonalexa.com/v1/proactiveEvents/' // 公開スキルでは、こちら

    const options = {
        method: 'POST',
        uri: uri,
        timeout: 30 * 1000,
        body: body,
        headers: {
            'Content-Type': 'application/json',
            'Content-Length': body.length,
            'Authorization' : 'Bearer ' + token
        }
    };
    await rp(options);
}

function mediaContentAvailableEvent() {

    let timestamp = new Date();
    let expiryTime = new Date();
    expiryTime.setMinutes(expiryTime.getMinutes() + 60);

    return {
        'timestamp': timestamp.toISOString(),
        'referenceId': 'id-'+ new Date().getTime(),
        'expiryTime': expiryTime.toISOString(),
        'event': {
            'name': 'AMAZON.MediaContent.Available',
            'payload': {
                'availability': {
                    'startTime': timestamp.toISOString(),
                    'provider': {
                        'name': 'localizedattribute:providerName'
                    },
                    'method': 'AIR'
                },
                'content': {
                    'name': 'localizedattribute:contentName',
                    'contentType': 'EPISODE'
                }
            }
        },
        'localizedAttributes': [
            {
                'locale': 'ja-JP',
                'providerName': 'proactive event api sample',
                'contentName': 'push notification test'
            }
        ],
        //'relevantAudience': {
        //    'type': 'Multicast',
        //    'payload': {}
        //}
        'relevantAudience': {
            "type": "Unicast",
            "payload": {
                "user": "amzn1.ask.account.XXX...XXXX"
            }
        }
    }
}

コードの解説は「Alexa Deep Dive」を見てください。変えたところだけ少し説明します。

mediaContentAvailableEvent

元の例では AMAZON.TrashCollectionAlert.Activated だったのですが、今回はAMAZON.MediaContent.Availableなので、それに合わせてリクエストBODYを変える必要があります。基本的にはドキュメントに記載のとおりにすれば良いのですが、少しハマるところもありました。

  • AMAZON.TrashCollectionAlert.Activatedでは指定できるパラメータがすべてENUMになっています。
        'event': {
            'name': 'AMAZON.TrashCollectionAlert.Activated',
            'payload': {
                'alert': {
                    'garbageTypes': [
                        'PET_BOTTLES',
                        'RECYCLABLE_PLASTICS',
                        'WASTE_PAPER',
                        'COMPOSTABLE',
                    ],
                    'collectionDayOfWeek': 'TUESDAY'
                }
            }
        },

例えば、garbateTypesにはゴミの種類を指定するのですが、予め決められたリストから選択します。ここを開発者独自のキーワードにすることはできません。

日本語も予め決まっているようで、特に何もしなくても日本語に勝手に置き換えられて話されます(RECYCLABLE_PLASTICS は「プラマークごみ」になる)。

mediaContentAvailableEventではこの部分を開発者が決めることができます。

        'event': {
            'name': 'AMAZON.MediaContent.Available',
            'payload': {
                'availability': {
                    'startTime': timestamp.toISOString(),
                    'provider': {
                        'name': 'localizedattribute:providerName'
                    },
                    'method': 'AIR'
                },
                'content': {
                    'name': 'localizedattribute:contentName',
                    'contentType': 'EPISODE'
                }
            }
        },

payload内のavailability.provider.name(コンテンツプロバイダー名)、content.name(コンテンツ名)がそれになります。ただしここで直接日本語で指定するのではなく、それぞれlocalizedAttributesで指定されたキーから引っ張ってくるように設定してあります。

localizedAttributesはこうなってます。

        'localizedAttributes': [
            {
                'locale': 'ja-JP',
                'providerName': 'proactive event api sample',
                'contentName': 'push notification test'
            }
        ],

payload内で、localizedattribute:キーワードで指定すると、localizedAttributesの各ロケールから自動的に読み込まれるわけですね。で、ここに指定したproviderNameとcontentNameを指定するわけですが、ここ、なぜか日本語はNGの様子なんですよね・・・。 ドキュメントにあるように日本語で記載すると、400 Bad Requestで返ってきちゃう。とりあえず、英語書いても日本語っぽく読み上げてくれるみたいなので、英語+ローマ字で書くしかないのかなって感じです。

あと、実際に話されるのはこういう感じになるのですが、

「<content.name>は、<availability.startTime>に<availability.provider.name>で<availability.method>されます。」

availability.methodの違いもイマイチわかりにくいです。STREAMは「ストリーミング」、AIR、RELEASE、DROPは「放送」、PREMIEREは「初めて放送」って感じで置き換えられてました。contentTypeは今回EPISODEを使ってますけど、もしかしたらこことの組み合わせで変わるのかもしれません。このあたり公式ドキュメントに記載してほしいなというところです。

最後にrelevantAudienceです。ここはまあ公式のドキュメントにも記載されているのでそれを読めば終わりなんですが、元の例では一斉通知になっていました。

        'relevantAudience': {
            'type': 'Multicast',
            'payload': {}
        }

特定のユーザだけに送る場合は、typeをUnicastにしてpayloadにuserIdを指定します。

        'relevantAudience': {
            "type": "Unicast",
            "payload": {
                "user": "amzn1.ask.account.XXX...XXXX"
            }
        }

これで指定されたユーザIDにのみ通知が飛びます。でも複数のユーザの場合はどう書くんでしょうね・・・個別に叩くしかないのかな?

スクリプトの実行

ではこれ通知を送信してみましょう。いくつかパッケージが必要なのでインストールします。

$ npm install request request-promise

クライアントIDとクライアントシークレットは環境変数から取得するようになっていますので、開発者コンソールで取得したクライアントIDとクライアントシークレットを環境変数に設定します。

$ export CLIENT_ID=amzn1.application-oa2-client.XXXXXXXXX
$ export CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX

実行します。

$ node push.js

エラーなく実行終了して、Echoデバイスに通知が来たら成功です!

その他

上記以外に、ちょっとハマったところをご紹介しておきます。

  • 私の手元には、Echoが数台あるのですが、通知を送ると、Echoだけが反応(黄色リング点滅)して、Echo Showの方は特に反応しませんでした。いろいろ設定を変えてみたりもしたのですが、変わらず・・・Amazonの配達通知やYahoo!天気はEcho Showで通知されるので、ちょっとよくわかりません。

追記2020/08/02:Twitterでコメント頂きました。アイコン登録したらEcho Showでも行けるようになりました!ありがとうございます!

  • プロアクティブイベントのスキーマ、設定をミスってエラーになっても"400 Bad Request"と"The payload is not a valid JSON"しか出ないので、何が悪いのかがわかりにくいです。これはもうドキュメントをしっかり見るしかないですね。あと、どういう通知が来るのかも実際に試してみないとわからないので、Try&Errorあるのみ・・・

まとめ

スキル側の準備はそれほど難しくないんですが、概念的にややこしいのと、送信がめんどくさいですね・・・イベントスキーマも非常に限定されているし、使い所はなかなか難しい気がします。

ただ、自由なタイミングでユーザへプッシュ通知できるというのは、他の機能にはない強みです。Alexa for Apps、App-to-App Account Linking、Quick Links等いろいろモバイル連携でスキルへ誘導する仕組みが増えていますが、直接的にユーザにリーチしていきたい、という点においてはまだまだ有用なのではないか?という気がしますね。

大体の仕組みと実装方法がわかったところで、実はPart2に続きます・・・こちらのほうが本題なので、ぜひお楽しみに!