クマは森で用を足しますか?

アウトプットは重要です。

Actions on Google 用に getSignedUrl() で署名付き URL を作る

Google アシスタントのアクションで使う画像ファイルの URL を署名付きにしようと思い、いろいろ試していました。先達の方々もその方法をブログ等に書き残されているのですが、みんなほんの少し違っていたりして。Firebase Admin SDK秘密鍵を使わずにできそうな記事を参考にしていたのですが、秘密鍵を使う方法でうまく動いてくれたので、ひとまずはそれで良いことにする。
ちなみに、今回作っていたアクションの話はこちらに。
cheerio-the-bear.hatenablog.com

BasicCard の画像取得用 URL

画像付き BasicCard を使ったアクションを作ろうと思い、次のスクリーンショットを表示するサンプルコードを書いて調べていました。この画像ファイルの URL を、署名付き URL にしています。

f:id:cheerio-the-bear:20190428161954p:plain
BasicCard (Phone)

スマートディスプレイでは、次のような表示になるようです。

f:id:cheerio-the-bear:20190428162019p:plain
BasicCard (Smart Display)

音声アシスタント端末側に届く URL は指定した利用期限に達するまで有効で、ブラウザにペーストしても同じ画像を表示してくれます。同じ URL を利用期限以降に利用しようとすると、下記のようにエラーが応答されます。

<Error>
  <Code>ExpiredToken</Code>
  <Message>The provided token has expired.</Message>
  <Details>
    Request signature expired at: 2019-04-21T13:38:38+00:00
  </Details>
</Error>

期待通りの結果が得られたようですが、結局のところこれ、使わないかもしれません。スマートディスプレイでは更にもうちょっと大きめの画像を常時表示したいのですが、スマートフォンではあまり通信量を多くしたくありません。しかし、現時点ではそれらを区別する良い方法が見当たらず、'actions.capability.WEB_BROWSER' もちょっと違う感じです。

developers.google.com

サンプルコードはこちら

'Default Welcome Intent' を契機に getSignedUrl() を使って URL を取得し、BasicCard を返すだけの実装です。Firebase Admin SDK秘密鍵は key.json という名前でプロジェクトに含めていて、画像ファイルは Firebase Storage の images フォルダの下に置いています。バケット名は適当に読み替えてください。

'use strict'

const { WebhookClient } = require('dialogflow-fulfillment')
const { BasicCard, Image, SimpleResponse } = require('actions-on-google')

const functions = require('firebase-functions')
const admin = require("firebase-admin")
const serviceAccount = require("./key.json")

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  storageBucket: 'my-sample-d582e.appspot.com'
})

const bucket = admin.storage().bucket()

process.env.DEBUG = 'dialogflow:debug'

exports.mySampleCode = 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))

    async function getSignedUrl(file) {
        let [signedUrl] = await bucket.file(file).getSignedUrl(
            { action: 'read', expires: Date.now() + 1000 * 5 * 60 }
        )
        console.log('Signed URL: ' + signedUrl)
        return signedUrl
    }

    async function welcome(agent) {
        let conv = agent.conv();

        conv.ask('This is the sample as you know.')
        conv.ask(new BasicCard({
            text: 'Sample Text',
            subtitle: 'Sample Subtitle',
            title: 'Sample Title',
            image: new Image({ url: await getSignedUrl('images/sample.png'), alt: 'Sample Image' }),
            display: 'CROPPED'
        }))

        agent.add(conv)
    }
 
    function fallback(agent) {
        agent.add('Would you please say that again?')
    }

    let intentMap = new Map()
    intentMap.set('Default Welcome Intent', welcome)
    intentMap.set('Default Fallback Intent', fallback)
    agent.handleRequest(intentMap)
})

package.json は、この通り。async/wait を使いたかったので、Node.js のバージョンは 8 にしました。

{
  "name": "my-sample-v1",
  "description": "My Sample",
  "version": "1.0.0",
  "author": "Sample <sample@gmail.com>",
  "engines": {
    "node": "8"
  },
  "dependencies": {
    "firebase-admin": "~7.0.0",
    "firebase-functions": "^2.2.0",
    "actions-on-google": "2.4.0",
    "dialogflow": "^0.6.0",
    "dialogflow-fulfillment": "^0.5.0"
  }
}

SSML 内で使う場合にはエスケープ処理を

効果音等の音声ファイルの署名付き URL も上述のように作成できますが、それを SSML 内の audio タグで利用する場合には、事前にエスケープ処理を施す必要があります。これが出来ていなくて構造が崩れてしまうと、SSML として記述した文字列をそのまま読み上げられてしまいます。

const escape = require('lodash/escape')

...

        let sound = escape(await getSignedUrl('sound.mp3'))

        conv.ask(new SimpleResponse({
            speech: '<speak><audio src="' + sound + '"/></speak>',
            text: 'Sample sound'
        }))

IAM API 関連のエラー

Firebase を使い慣れていない方は、やはり同様に IAM API 関連のエラー表示を見ることになると思います。表示されたメッセージの中で指示されている通りに設定を変更すれば解決されます。

IAM API を有効にする

IAM API を有効にしていないと、まずこのエラーに見舞われます。

Error: A Forbidden error was returned while attempting to retrieve an access token for the Compute Engine built-in service account. This may be because the Compute Engine instance does not have the correct permission scopes specified. Identity and Access Management (IAM) API has not been used in project 1004610467434 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/iam.googleapis.com/overview?project=1004610467434 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.
    at Gaxios.<anonymous> (/srv/node_modules/gaxios/build/src/gaxios.js:73:27)
    at Generator.next (<anonymous>)
    at fulfilled (/srv/node_modules/gaxios/build/src/gaxios.js:16:58)
    at <anonymous>
    at process._tickDomainCallback (internal/process/next_tick.js:229:7)

エラーメッセージの中で、ご丁寧にリンクまで紹介されています。それを使って設定画面へ進み、IAM API を有効にします。

f:id:cheerio-the-bear:20190428170313p:plain
IAM API を有効にする

サービスアカウントトークン作成者権限を与える

IAM API を有効にした後、次に出会うエラーメッセージはきっとこれだと思います。

Error: A Forbidden error was returned while attempting to retrieve an access token for the Compute Engine built-in service account. This may be because the Compute Engine instance does not have the correct permission scopes specified. Permission iam.serviceAccounts.signBlob is required to perform this operation on service account projects/my-sample-d582e/serviceAccounts/my-sample-d582e@appspot.gserviceaccount.com.
    at Gaxios.<anonymous> (/srv/node_modules/gaxios/build/src/gaxios.js:73:27)
    at Generator.next (<anonymous>)
    at fulfilled (/srv/node_modules/gaxios/build/src/gaxios.js:16:58)
    at <anonymous>
    at process._tickDomainCallback (internal/process/next_tick.js:229:7)

サービスアカウント 'xxx@appspot.gserviceaccount.com' の権限が足りていないことによるものだそうです。エラーメッセージ中で言及されたサービスアカウントを IAM 設定画面上で見つけ出し、Service Accounts > Service Account Token Creator を追加することで解決します。

f:id:cheerio-the-bear:20190428172601p:plain
サービスアカウントトークン作成者権限を与える