inosyanのブログ

プログラム、家庭菜園、僕が興味を持ったことを書いていきます

Googleアシスタント用アプリ「バトルゲーム」をリリースしてわかった開発の注意点

f:id:inosyan:20181123231403p:plain

 前回の記事「指輪型ガジェット「ORII」のBackerになった」で書いたように、音声アシスタントに興味がでてきたので、Googleアシスタント用のアプリを作ってみました。

inosyan.hateblo.jp

 こちらが実際に動いている様子です。

 興味を持ってからアプリを作ってリリースするまで、そんなに大変ではありませんでしたが、いくつか注意すべき点もあるので開発に興味を持った方のためにメモを残します。作り方の手順についてはいろんな方が説明されていますのでそちらをご覧ください。

 Actions on Googleで新規にプロジェクトを作り、Actionsでアクションを作るとDialogflowにエージェントが作られます。DialogflowにはFulfillmentという画面があり、そこがプログラムを書く場所です。そこの画面でInline EditorをEnableにすると、サンプルのプログラムを見ることができます。

f:id:inosyan:20181123231423p:plain

サンプルコードはこのようになっています。

// See https://github.com/dialogflow/dialogflow-fulfillment-nodejs
// for Dialogflow fulfillment library docs, samples, and to report issues
'use strict';
 
const functions = require('firebase-functions');
const {WebhookClient} = require('dialogflow-fulfillment');
const {Card, Suggestion} = require('dialogflow-fulfillment');
 
process.env.DEBUG = 'dialogflow:debug'; // enables lib debugging statements
 
exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
  const agent = new WebhookClient({ request, response });
  console.log('Dialogflow Request headers: ' + JSON.stringify(request.headers));
  console.log('Dialogflow Request body: ' + JSON.stringify(request.body));
 
  function welcome(agent) {
    agent.add(`Welcome to my agent!`);
  }
 
  function fallback(agent) {
    agent.add(`I didn't understand`);
    agent.add(`I'm sorry, can you try again?`);
}

  // // Uncomment and edit to make your own intent handler
  // // uncomment `intentMap.set('your intent name here', yourFunctionHandler);`
  // // below to get this function to be run when a Dialogflow intent is matched
  // function yourFunctionHandler(agent) {
  //   agent.add(`This message is from Dialogflow's Cloud Functions for Firebase editor!`);
  //   agent.add(new Card({
  //       title: `Title: this is a card title`,
  //       imageUrl: 'https://developers.google.com/actions/images/badges/XPM_BADGING_GoogleAssistant_VER.png',
  //       text: `This is the body text of a card.  You can even use line\n  breaks and emoji! 💁`,
  //       buttonText: 'This is a button',
  //       buttonUrl: 'https://assistant.google.com/'
  //     })
  //   );
  //   agent.add(new Suggestion(`Quick Reply`));
  //   agent.add(new Suggestion(`Suggestion`));
  //   agent.setContext({ name: 'weather', lifespan: 2, parameters: { city: 'Rome' }});
  // }

  // // Uncomment and edit to make your own Google Assistant intent handler
  // // uncomment `intentMap.set('your intent name here', googleAssistantHandler);`
  // // below to get this function to be run when a Dialogflow intent is matched
  // function googleAssistantHandler(agent) {
  //   let conv = agent.conv(); // Get Actions on Google library conv instance
  //   conv.ask('Hello from the Actions on Google client library!') // Use Actions on Google library
  //   agent.add(conv); // Add Actions on Google library responses to your agent's response
  // }
  // // See https://github.com/dialogflow/dialogflow-fulfillment-nodejs/tree/master/samples/actions-on-google
  // // for a complete Dialogflow fulfillment library Actions on Google client library v2 integration sample

  // Run the proper function handler based on the matched Dialogflow intent name
  let intentMap = new Map();
  intentMap.set('Default Welcome Intent', welcome);
  intentMap.set('Default Fallback Intent', fallback);
  // intentMap.set('your intent name here', yourFunctionHandler);
  // intentMap.set('your intent name here', googleAssistantHandler);
  agent.handleRequest(intentMap);
});

 コメントアウトしてある関数が2箇所あります。コメントアウトを解除してintentMapに紐づけている箇所のコメントも外します。 'your intent name here' のところはあらかじめ作っておいたインテントの名前を書きます。

 2つの関数のうち、yourFunctionHandler のほうは問題ないのですが、googleAssistantHandlerのほうを有効にして、シミュレーターで実行しようとするとエラーが出ます。

f:id:inosyan:20181123231450p:plain

Failed to parse Dialogflow response into AppResponse because of empty speech response. 

 これだけだと原因がわからないので、プログラムで何が起きてるのかログを見てみます。画面下のリンクからFirebaseのログが開きます。

f:id:inosyan:20181123231507p:plain

 ログにはこのようにありました。

f:id:inosyan:20181123231522p:plain

TypeError: Cannot read property 'forEach' of undefined
    at V2Agent.addActionsOnGoogle_ (/user_code/node_modules/dialogflow-fulfillment/src/v2-agent.js:313:28)
    at WebhookClient.addResponse_ (/user_code/node_modules/dialogflow-fulfillment/src/dialogflow-fulfillment.js:269:19)
    at WebhookClient.add (/user_code/node_modules/dialogflow-fulfillment/src/dialogflow-fulfillment.js:245:12)
    at googleAssistantHandler (/user_code/index.js:49:12)
    at WebhookClient.handleRequest (/user_code/node_modules/dialogflow-fulfillment/src/dialogflow-fulfillment.js:303:44)
    at exports.dialogflowFirebaseFulfillment.functions.https.onRequest (/user_code/index.js:60:9)
    at cloudFunction (/user_code/node_modules/firebase-functions/lib/providers/https.js:57:9)
    at /var/tmp/worker/worker.js:714:7
    at /var/tmp/worker/worker.js:697:11
    at _combinedTickCallback (internal/process/next_tick.js:73:7)

agentの内部で起きている問題のようです。解決するには actions-on-google のバージョンを変えます。

タブを index.js から package.json に切り替えます。

f:id:inosyan:20181123231544p:plain

package.json

{
  "name": "dialogflowFirebaseFulfillment",
  "description": "This is the default fulfillment for a Dialogflow agents using Cloud Functions for Firebase",
  "version": "0.0.1",
  "private": true,
  "license": "Apache Version 2.0",
  "author": "Google Inc.",
  "engines": {
    "node": "8"
  },
  "scripts": {
    "start": "firebase serve --only functions:dialogflowFirebaseFulfillment",
    "deploy": "firebase deploy --only functions:dialogflowFirebaseFulfillment"
  },
  "dependencies": {
    "actions-on-google": "^2.1.0",
    "firebase-admin": "^5.13.1",
    "firebase-functions": "^2.0.2",
    "dialogflow": "^0.6.0",
    "dialogflow-fulfillment": "^0.5.0"
  }
}

このうち、 dependenciesの

    "actions-on-google": "^2.1.0",

    "actions-on-google": "2.4.0",

に変更すると直ります。 サンプルとバージョンの不一致の問題なので、近い将来には解決すると思います。
 
 

デプロイまでに時間がかかるのでその対策

プログラムを修正してDeployボタンを押すとデプロイを開始します。
f:id:inosyan:20181123231602p:plain

そしてしばらくするとデプロイが終わったと表示されます。
f:id:inosyan:20181123231618p:plain

でもこの時にはまだシミュレーターで見ても修正が反映されていません。反映までにはここから1〜2分ほどかかるようです。反映されたかどうかを知るため、僕の場合は会話が開始された時のインテント「Default Welcome Intent」の最初のセリフにバージョンを喋らせています。

agent.add(`バージョン${VERSION}`);

 
 

日本語のプライバシーポリシーが必要

 作ったアプリをリリースするにはプライバシーポリシーを用意しなければなりません。ですがほとんどの人は作ったことはないと思います。Googleもそこはわかっているようで、テンプレートを用意してくれています。
f:id:inosyan:20181123231639p:plain

プライバシーポリシーの画面で Need help creating a Privacy Policy? を選ぶと、このようなダイアログが開きます。 f:id:inosyan:20181123231659p:plain

 ここの sample doc のリンクを開くと、テンプレートのドキュメントが開くのですが、テンプレートの説明は書いてあるのに肝心のテンプレートがありません。

テンプレート内容が書かれていないサンプルドキュメント

f:id:inosyan:20181123231720p:plain

 運良く僕は、テンプレートが書かれているバージョンのリンクを知っていました。少し前に作ったプロジェクトの同じダイアログにある sample doc のリンクには、別のドキュメントへのリンクが設定されていて、そちらのドキュメントにはテンプレートの文章が書かれていました。

テンプレート内容が書かれているサンプルドキュメント

f:id:inosyan:20181123231741p:plain

 このテンプレートの指示どおり、${APPNAME}と${DEVELOPER}の箇所を自分のアプリ名と開発者名に置き換えて、手順の箇所を削除して共有設定を誰でも見れるようにしたものをプライバシーポリシーとして申請しました。

 翌日、審査に落ちたとのメールが来ました。理由はプライバシーポリシーが日本語では無かったからです。僕が作ったアプリは日本語用だったので、その場合は日本語のプライバシーポリシーが必要とのことでした。日本語に訳して再提出したら審査に通りました。ご参考までにそのドキュメントのリンクを載せておきます。もしこれを元に作成する場合は、アプリ名「バトルゲーム」と、開発者名「イノシャン」は、ご自身の情報で置き換えてください。くれぐれもよろしくお願いします。

バトルゲーム プライバシーポリシー
 
 

アプリは「終了」で終わらせる必要がある

 はじめの審査で落ちた理由はもう一つあり、それは「終了」の言葉でアプリが終わらなかったことです。アプリが終了したときに連勝記録を喋らせたかったので、デフォルトのインテントは使わずFulfillmentに処理を書いて起き、「中断」という言葉でその処理を行うようにしていました。ですが、「終了」という言葉でも終わらせないといけないようです。なので、こんな感じで Entity の「中断」を「終了」に変え、念のため似たような言葉に反応するようにしました。

f:id:inosyan:20181123231756p:plain

ちなみに、conv.close を呼ぶと終了させることができるようです。

        const conv = agent.conv();
        conv.close(msg);
        agent.add(conv);

 
 

会話ごとに保持する変数の扱い

 作り方のサンプルを見ると、例えばホテルの予約システムの場合、システムが提示した選択肢の中からユーザーが選んだものを会話中システムが保持することは出来るようです。ですが、例えば「連勝記録」のような、ユーザが選択肢から選ぶようなものではなく、システムが増減させることのできる変数をどうやって作るのか、はじめは分かりませんでした。

 例えば 'win' という変数に連勝記録を保持するとします。その変数を function の外側に置いておけば、連勝記録は保持されます。一見うまくいったように見えますが、これがうまくいくのは同時にプレイしているユーザーが一人の時だけです。2台の端末から同時にアクセスするとどちらも同じ連勝回数になってしまい、混乱してしまいます。

 このことから、どうやらこのプログラムは、会話ごとに別のインスタンスが作られるわけではなく、1つのインスタンスがすべての会話を処理していることがわかります。なので変数はテーブルにして、会話ごとにデータを分けるようにしました。 request.body.session は会話のセッションごとに違う値が入っているので、キーとして使えそうです。

const userId = request.body.session;
statusTable[userId] = data;

 ただ、このままでは会話ごとにデータが増えるのでメモリが心配です。終了コマンドが呼ばれたらdeleteするようにはしていますが、途中で会話をやめるかもしれず、そうなるとデータが残り続けてしまいます。なので、会話ごとのデータの更新日も記録し、1日以上経った古いデータを消す処理を会話の開始時に行うようにしています。

function deleteOldData() {
    const date = new Date();
    date.setDate(date.getDate() - 1);
    Object.keys(statusTable).forEach(key => {
        if (statusTable[key].date.getTime() < date.getTime()) {
            delete statusTable[key];
        }
    });
}

 もし1日に何百万人もアクセスするようなコンテンツなら、全件ループするこのような方法は良くないですが、GoogleアシスタントやDialogflowはアクセス数の上限があるのでその心配はありません。DBを使う手もありますが、今回のゲームはシンプルに作りたかったので使いませんでした。もっとスマートなやり方があるのかもしれませんが、今回はこうしました。


 いくつか注意が必要な箇所はありましたが、それでもリリースまでわりと簡単に出来て面白いと思いました。審査も1回落ちましたが申請して翌日には結果が返ってくるので合計2日しかかかりませんでした。開発は絵を書くこと以外はすべてWebブラウザ上で出来ました。コードもInline Editorで書きましたが、行数が多くなるとブラウザでは狭いので、ローカルで書いたものをデプロイするほうが良いかもしれません。ですが、簡単な処理だけしか書かないのならInline Editorで充分です。気軽にできるので趣味でプログラムを書く人にもおすすめです。