GoogleHome
actionsongoogle
dialogflow

Dialogflow V2 の Inline Editor 用コードテンプレート

Inline Editor 用コードテンプレート

Dialogflow の Fullfillment にある Inline Editor は、Web ブラウザーだけでアプリを作れるのでとても便利です。

ただ、デフォルトで表示されるコードが公式ドキュメント(例えば Actions on Google Node.js Client Library Version 1 Migration Guide)と違っているのでとっても不便。

そこで、以下のコードをベースに開発するようにしています。gist

'use strict';

const functions = require('firebase-functions');
const { dialogflow } = require('actions-on-google');

const app = dialogflow();

app.intent('Default Welcome Intent', conv => {
    conv.close('hello');
});

exports.dialogflowFirebaseFulfillment = functions.https.onRequest(app);

ポイント1

app.intent() には、インテント名(Default Welcome Intent)を使います。V1で使っていたアクション名(input.welcome)ではありません。

ポイント2

exports.dialogflowFirebaseFulfillment を使います。上記 Migration Guide では exports.factsAboutGoogle になっています。package.jsonでこの関数名を使うように指定してあります。

動作させるための事前準備

  1. Dialogflow console の左ペイン Intents > Default Welcome Intent を選択し、ページ一番下の Fullfillment で Enable webhook call for this intent をチェックして Save ボタンを押下
  2. 同じく左ペイン > Fullfillment の Inline Editor を Enable にして、index.js に上記コードをコピーしてから Deploy ボタンを押下

inline-editor.png

ちなみに、Inline Editor の 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": "~6.0"
  },
  "scripts": {
    "start": "firebase serve --only functions:dialogflowFirebaseFulfillment",
    "deploy": "firebase deploy --only functions:dialogflowFirebaseFulfillment"
  },
  "dependencies": {
    "actions-on-google": "2.0.0-alpha.4",
    "firebase-admin": "^4.2.1",
    "firebase-functions": "^0.5.7",
    "dialogflow": "^0.1.0",
    "dialogflow-fulfillment": "0.3.0-beta.3"
  }
}

背景

Dialogflow API が V2 になったタイミングで、Inline Editor に表示されるデフォルトのコードが dialogflow-fullfillment-nodejs に変更されましたが、公式ドキュメントの書き方と合っていません。

そこで Actions on Google client library for Node.js v2.0.0 を参考に書き直しました。

アプリ開発に役立つ機能

上記コードをベースにして、いろいろな機能を使ってみます。

注意事項

Google Cloud Console 経由で Cloud Functions の設定を変更できますが、一度でもそれをやると Dialogflow の Inline Editor が使えなくなります!
また、ターミナルで gcloud コマンドを使っても同様に使えなくなります。
一度使えなくなると、ずっと使えないまま(戻せない)ので注意してください。

ユーザーとの「よくある」やりとりをお手軽に作る

公式ドキュメント: https://developers.google.com/actions/assistant/helpers

Helper を使うことで、Intent を作る手間を省けます。Helper に対応する Dialogflow event がそれぞれ存在します。

楽できる反面、できないこともあるので注意が必要です(後述)。

ユーザーの情報を聞き出す例 (Permission)

Permission クラスと actions_intent_PERMISSION イベントを使って、ユーザーの名前を取得する例を示します。具体的には以下のやりとりを行います。

GoogleHome: 挨拶をするために、名前が必要です。Googleの情報を利用してもよいでしょうか。

あなた: はい

GoogleHome: <あなたの名前>さん、こんにちは。

具体的な手順は次の通り。

  1. 新しい Intent を作り、対応する Dialogflow event を受け付けるように設定します。ask_for_permission_confirmation という名前をインテントを作り、Events に actions_intent_PERMISSION を指定し、Fulfillment を enabled にします。

  2. Inline Editor に以下のコードを入力して Deploy します。最初に Permission を使えるように require() しておきます。Default Welcome Intent で conv.ask(new Permission()) してユーザーに許可を求めます。

'use strict';

const functions = require('firebase-functions');
const { dialogflow, Permission } = require('actions-on-google');

const app = dialogflow();

app.intent('Default Welcome Intent', conv => {
    const options = {
        context: '挨拶をするために',
        // NAME, DEVICE_PRECISE_LOCATION, DEVICE_COARSE_LOCATION から複数指定できる
        permissions: ['NAME']
    };
    conv.ask(new Permission(options));
});

app.intent('ask_for_permission_confirmation', (conv, params, confirmationGranted) => {
    if (confirmationGranted) {
        // NAMEの場合
        const {name} = conv.user;
        if (name) {
            return conv.close(`${name.display}さん、こんにちは`);
        }
        // DEVICE_PRECISE_LOCATIONの場合
        // const {location} = conv.device;
    }
    conv.close(`お名前を伺うことができませんでした。`);
});

exports.dialogflowFirebaseFulfillment = functions.https.onRequest(app);

できないことの例(Confirmation)

Confirmation クラスと intent_action_CONFIRMATION イベントを使えば、ユーザーに「はい」「いいえ」で答えてもらうことができるようになります。
しかし、BasicCard を使ったビジュアルな返信と組み合わせることができません。
具体的には、conv.ask(new Confirmation()) する前に conv.ask(new BasicCard()) で指定していた返信は無視されます。

そんなときは自分で Yes用/No用の Intent をそれぞれ作る必要があります。Dialogflow 上で Followup Intent を作るとよいでしょう。

回避できないのかな...。

デバイスの位置情報

Permission クラスと actions_intent_PERMISSION イベントを使います。

app.intent('nearby', conv => {
    conv.ask(new Permission({
        context: '付近の鳥を探すために',
        permissions: ['DEVICE_PRECISE_LOCATION']
    }));
    // conv.ask(new Suggestions(['はい', 'いいえ'])); // 適用されない。
});

app.intent('permission_confirm', (conv, params, granted) => {
    if (granted) {
        const {coordinates} = conv.device.location;
        console.log(corrdinates.latitude);
        console.log(corrdinates.longitude);
        ...

困っていること

actions_intent_PLACE の使い方が分からないので、どなたか教えて下さい...。

データを保存する

公式ドキュメント: https://developers.google.com/actions/assistant/save-data

アプリが起動して終了するまでの間なら conv.data

conv.data を使います。例えば config に {ver: 1} を保存する場合、

conv.data.config = {ver: 1};

永続的に保存するなら conv.user.storage(ただし条件付)

ユーザーが「Voice Match でアカウントに基づく情報を受け取る」ようになっている場合には、conv.user.storage が使えます。

ただし、個人情報を保存する場合には注意が必要です。国によっては、ユーザーの同意が必要です(例えば、EU 一般データ保護規則)。また、アプリを公開する時に作成するプライバシーポリシーへの記載も必要になります。

Voice Match が設定されておらず、ユーザーを特定できない場合には conv.user.storage は使えず、常に {} になっているので注意してください。

Google Home で Voice Match を設定する

conv.user.storage.config = {ver: 1};

音楽を再生する

まるまる一曲やポッドキャストを再生する場合は MediaResponses を使います。それ以外は SSML を使います。

どちらも音楽のURLを指定しますが、Firebase の無料プランだと google 外の URL を呼び出せないので注意してください。

MediaResponse API を使う

公式ドキュメント: https://developers.google.com/actions/assistant/responses#media_responses

2018/05/06時点で、iOS の Google アシスタントは対応していない。なので、MEDIA_RESPONSE_AUDIO で再生前にチェックを入れている。

MediaObject の前に、必ずテキスト or SSML レスポンスを入れることに注意してください。これを忘れると、

  • conv.close の場合、MalformedResponse at final_response.rich_response: the first element must be a 'simple_response', a 'structured_response' or a 'custom_response' というエラーが出ます。

  • conv.ask の場合、alformedResponse at expected_inputs[0].input_prompt.rich_initial_prompt: the first element must be a 'simple_response', a 'structured_response' or a 'custom_response' というエラーが出ます。

大きな画像を表示したい場合は image を、小さな画像は icon を指定します。なお、Image を require すると Inline Editor に Redifinition of Image という警告が出るけれど無視しましょう。

const { dialogflow, MediaObject, Image, Suggestions } = require('actions-on-google');
...
const play = (conv, name, url, desc, imageUrl, alt) => {
    if (!conv.surface.capabilities.has('actions.capability.MEDIA_RESPONSE_AUDIO')) {
        return conv.close('このデバイスは音楽を再生できません。');
    }
    conv.close(`${name}を再生します。`);
    conv.close(new MediaObject({
        name: name,
        url: url,
        description: desc,
        //icon: new Image({
        image: new Image({
          url: imageUrl,
          alt: name
        })
    }));
};

conv.ask(new MediaObject) より conv.close(new MediaObject) がオススメです。 アプリが一度終了した上で、音楽再生がはじまるので、GoogleHome デフォルトのコマンド(音量を変える、再生をストップするなど)が使えるようになるためです。

悩ましい問題

actions console の Overview > Surface capabilities で、media playback 有りにすれば iOS / Android のアプリ紹介 (actions directory) には表示されなくなるのだけれど、露出が減って利用者が増えないので困る...。

surface.png

SSML を使う

公式ドキュメント: https://developers.google.com/actions/reference/ssml

BGMをフェードアウトさせつつ音声を再生するなど結構複雑なことができます。actions console の Simulator でお試し再生できるのも便利です。ただし、URL は https でないと再生できないので注意してください。

公式ドキュメントだけだと分かりづらいので、https://medium.com/@silvano.luciani/more-ssml-for-actions-on-google-e365af89e56d を読むことをオススメします。。

サンプルは https://github.com/actions-on-google/dialogflow-ssml-nodejs にある。

なお、SSML で音楽を再生すると処理時間がかかり、GoogleHome からの応答に時間がかかるようになる。だいぶイライラするので注意。

なお、2分以上の音楽を再生する場合は、前述した MediaResponses API を使ってください。2分以上 GoogleHome をしゃべらせると審査で落とされます。120秒ルール。

HTTP API を呼び出す

request を使うと楽です。ただし、intent handler から Promise を return する必要があるので注意してください(後述)。

また、Firebase の無料プランだと google 外の URL を呼び出せないので注意してください。

Inline Editor の package.json に request を追加

{
  ...
  "dependencies": {
    ...
    "request": "^2.88.0",
  }
}

Inline Editor の index.js で HTTP API を呼び出す

const request = require('request');

const get = (url) => new Promise((resolve, reject) => {
    request.get({
        url: url,
        json: true
    }, (error, res, json) => {
        if (error) {
            return reject(error);
        }
        resolve(json);
    });    
});

...

app.intent(<インテント名>, conv => {
    const url = 'https://example.com/hello';

    // 必ず return すること!
    return get(url).then(responsedJson => {
        // responsedJson に対する処理
    })
});

Intent handler helpers and arguments より引用:

Additionally, async tasks now have built-in direct support in the library. To perform an async task, you must return a Promise to the intent handler.

時刻を扱う

Firebase Functions はタイムゾーンが UTC なので、new Date() すると9時間前の時刻になります。

moment.js を使って、の locale に conv.user.locale を設定しましょう。

参考: 5 Tips when Localizing your Action (adding different languages)

packages.json
...
  "dependencies": {
    ...
    "moment": "^2.23.0"
  }
...

ミドルウェアで locale を設定します。

const moment = require('moment');
...
app.middleware(conv => {
    moment.locale(conv.user.locale);
});

使い方

// エープリルフールかどうか?
const isAprilFool = () => {
    const now = moment();
    return now.month() + 1 === 4 && n.date() === 1;
};

Firebase Functions が障害で使えない場合に備えて Text response を設定しておく

Firebase Functions が障害で使えないことがありました。滅多にあることではないのですが。

念のため、Dialogflow 上で Default Welcome Intent や、Explicit invocation で起動する Intent の Text response に

アプリの不具合、もしくは、Google の障害で利用することができません。少し時間をおいて、改めてお試しください。

と記述していると、悪い印象を軽減できます。

Dialogflow で Intent の Responses は Set this intent as end of coversation をチェックしておきます。

アプリがどう使われているかを Google Analytics で分析する

Google Analytics で Track ID を払い出します。
Web を選択して作ります(Mobile App を選択すると firebase analysis へ誘導されるが、Android、iOS しか選択できず、JavaScript では作れません)。

分析したいところで次の関数を呼び出します。

const trackEvent = (conv, category, action, label, value) => {
    const data = {
        v: '1',
        tid: 'UA-XXXXXXXXX-X',
        cid: conv.user.id,
        t: 'event',
        ec: category,
        ea: action
    };
    if (label) {
        data.el = label;
    }
    if (value) {
        data.ev = value;
    }
    request.post('http://www.google-analytics.com/collect', {
        form: data
    });
};
  • category: イベントのカテゴリー (例: Video)
  • action: 対象に対する操作 (例: play)
  • label: 必須ではない。説明用の文字列 (例: 再生したビデオのタイトル)
  • value: 必須ではない。説明用の値 (例: 再生したビデオの人気度)

なお、cid (クライアントID) ではなく uid (ユーザーID) を使うと、Realtime ではイベントを受信しているのに、次の日に記録に残っていません。GDPR が関係しているのでしょうか?

すみません。よく聞き取れませんでした (NO_INPUT)

ユーザー入力がない場合に対応するため、Intent とコードを準備する必要があります(node.js client library v1 の時は、ask の後に reprompt の内容を渡せばよかったんだけど、使えなくなりました)。

公式: https://developers.google.com/actions/assistant/reprompts#dynamic_reprompts

以下でやっているのは、Normal Intent の方です。

  1. actions_intent_NO_INPUT イベントを受け取る Intent を作って、Fulfillment を呼び出す。
  2. 呼び出された Fulfillment で処理する。

この時、Fulfillment のコード例が Best Practices に掲載されているのですが、間違って書いてある疑惑。

Best Practices: https://developers.google.com/actions/assistant/best-practices#let_users_replay_information

node.js client library v2 だと、conv.ask の第2引数に、ユーザー入力がない場合の返信を返す機能が削られています。

nodejs v2: https://actions-on-google.github.io/actions-on-google-nodejs/classes/dialogflow.dialogflowconversation.html#ask

なので、こんな感じのユーティリティを作って使っています:

const { dialogflow, Suggestions } = require('actions-on-google');
...
const ask = (conv, inputPrompt, suggestions = null) => {
    conv.data.inputPrompt = inputPrompt;
    conv.data.suggestions = suggestions;
    if (suggestions) {
        conv.ask(inputPrompt);
        return conv.ask(new Suggestions(suggestions));
    }
    return conv.ask(inputPrompt);
};

const repeat = (conv, speech = '') => {
    if (conv.data.suggestions) {
        conv.ask(conv.data.inputPrompt);
        return conv.ask(new Suggestions(conv.data.suggestions));
    }
    return conv.ask(`${speech}${conv.data.inputPrompt}`);    
};

app.intent('Default Welcome Intent', conv => {
    ...
    ask(conv, 'はい。案内アプリです。やりたいことを教えてください。', ['ミニゲーム', '使い方']);
});

app.intent('no_input', conv => {
    return repeat(conv, 'すみません。 よく聞き取れませんでした。');    
});

Firebase Functions から データベースを使う

Realtime database と、ベータ版の Firestore が使えます。Firestore は検索機能が豊富ですが、読み取り速度に少々難があるためすぐに移行できるとは限らないようです。今後に期待。

事前準備 firebase-admin を設定する。

どちらのデータベースを使うにしても、firebase-admin を利用してアクセスします。
まず、package.json を修正して、firebase-admin と firebase-function のバージョンを新しくします。

pacakge.json
{
  ...
  "dependencies": {
    ...
    "firebase-admin": "^6.1.0",
    "firebase-functions": "^2.1.0",

次に、index.js に追加します。

index.js
// 追加
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);
// Realtime Database を使う場合
var rdb = admin.database();
// Firestore を使う場合
var db = admin.firestore();

詳しい使い方は公式ドキュメントをご覧ください。

データの読み書きをするときは、HTTP通信と同様に、intent handler から Promise を return する必要があるので注意してください。

Realtime Database でデータを読み取る

RealtimeDatabase
app.intent('show-list', conv => {
    const path = '/path/to/data'; // スラッシュからはじまる
    // Promise を return することに注意
    return rdb.ref(path).once('value', snapshot => {
        return conv.ask(dataToSsml(snapshot.val()));
    }, error => {
        console.error(error + ': ' + path);
        return conv.close('データベースを参照できませんでした。終了します。');
    });
});

Firestore でデータを読み取る

Firestore
app.intent('show-list', conv => {
    const path = 'path/to/data'; // 先頭のスラッシュ不要
    // Promise を return することに注意
    return db.doc('path/to/data').get().then(doc => {
        if (!doc.exists) {
            console.error(`no doc for ${path}`);
            return conv.close('データベースを参照できませんでした。終了します。'));
        }
        return conv.ask(dataToSsml(doc.data()));
    }).catch(error => {
        console.error(error + ': ' + path);
        return conv.close('データベースを参照できませんでした。終了します。'));
    });
});

Google Apps Script からデータベースを使う

GAS はスケジュールを指定して実行できるのがとても便利です。cron の代わりになります。

GAS から Realtime Database を更新する

https://sites.google.com/site/scriptsexamples/new-connectors-to-google-services/firebase を使えば Realtime Database の読み書きが可能になります。

Database Secret を払い出します。払い出したkeyは誰にも見つからないようにしてくださいね。

GAS から Firestore を更新する

Firestore for Google Apps Scriptsを使えばFirestore の読み書きが可能になります。

GAS用にサービスアカウントを作ります。払い出したkeyは誰にも見つからないようにしてくださいね。

その他、知っていると便利なこと(これから追記する予定)

  • XML を処理する