Google HomeとSalesforceをAccount Linkingで連携した（動画あり）

Quip Live Appsの何かを書くと予告していたのですが、すみません。予告詐欺です。

Google Homeから音声でSalesforceを操作するGoogle Homeアクションを作ってみることになりました。

# 今回作るアクションのシナリオ

1. Salesforceのアカウントと紐付ける
2. ユーザ「Salesforceアプリにつないで」
3. ボット「こんにちは。Salesforceアプリです。」
4. ユーザ「今日の予定は？」
5. ボット「今日の予定はXXXです。」

みたいなのを作ろうと思います。

内容はとりあえず連携を試すためのものではあります。
しかし、実はGoogle Homeは個人用Gmailアカウントとリンクしており、Googleカレンダー連携機能が役に立たないため、Salesforceで予定が確認できると個人的に便利なのではないかと期待もしています。

動くものをすぐに見てみたい方は下の方に**動画**がありますので、そちらをご覧ください。

# Account Linkingの設定

Salesforceと連携するにはどうしたってユーザ認証する必要があります。
Google Homeには[Account Linkings](https://developers.google.com/actions/identity/)という仕組みがあるのでこれを利用します。

## Salesforce側の設定

以下のような設定で接続アプリケーションを作成します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/43e645af-c5c2-4bc2-c0c1-48d7969e0eb2.png)

- コールバックURL: `https://oauth-redirect.googleusercontent.com/r/<google developer project ID>`
- 選択したOAuth範囲: full（テストなので）、refresh_token

## Actions Consoleの設定

Dialogflowでシミュレータが動くところまでやっておきます。

Overviewから(5)Account Linkingを選び[Add]をクリック

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/8bde2295-4769-8a1f-b6ff-6e222f751b65.png)

順番に入力していきます。

- Client ID: 上で作成した接続アプリケーションのコンシューマ鍵（翻訳おかしい）
- Client secret: 上で作成した接続アプリケーションのコンシューマの秘密（翻訳おかしい）
- Authorization URL: https://login.salesforce.com/services/oauth2/authorize
- Token URL: https://login.salesforce.com/services/oauth2/token
- Scopes:
 - refresh_token
 - full
- Testing instructions: レビュー用の項目なので適当に入力

Dialogflowを開き、[Sign in required for welcome intent]にチェック

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/bd537926-9619-8a18-b616-c456e7989e09.png)

## 認証連携の確認

シミュレータで確認すると、「まだリンクされていません」と表示されます。
（この状態でGoogle Home実機に話しかけても同じことになります。）

[DEBUG]タブにURLが表示されるので、そのURLをブラウザで開きます。
（Google Homeアプリからもできるらしいのですが、見当たりませんでした。開発版ではできないのだろうか？）

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/72c2119f-64d2-e971-dc8f-c5665d2ec0b2.png)

Salesforceへのログイン画面が表示されるのでログイン。

シミュレータに戻って再度アプリを呼び出すと、今度はちゃんと動き、accessTokenが取れていることがわかります。
（この状態でGoogle Home実機に話しかけても同じことになります。）

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/1c5b1130-ae66-4e93-5726-db412623b73b.png)

# アプリの実装

アカウント連携ができたので、アプリを実装していきます。
今回はDialogflowとCloud Functions for Firebaseを使っていきます。

## Entities

| エンティティ名       |       内容 |
|:-----------------|:------------------|
| end  |終了コマンド|
| object |Salesforceのオブジェクト名|
| what-to-know |「教えて」や「知りたい」という単語|

### end

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/6e3da2b0-9314-5f2a-14e8-347c8155fa58.png)

### object

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/8e3cf7e8-de04-721a-5019-9db0ed333ce4.png)

### what-to-know

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/e82f9fc8-edab-27ad-eeef-730a522201b5.png)

正直、どんな粒度でどのように定義すればいいかよくわかっていません。

## Intents

| インテント名       |       内容 |
|:-----------------|:------------------|
| Default Welcome Intent  |アプリを起動したとき|
| Default Fallback Intent |わからないとき|
| GetRecordsByDate        |メイン<br>日付とオブジェクト名でSalesforceのレコードを取得します。|
| End                     |終了するとき|

### Default Welcome Intent

Text Responseを変更しただけでそれ以外はデフォルトのままです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/cbcd6061-2a73-c11b-75a2-eb0845a58a07.png)

### Default Fallback Intent

こちらは後でFulfillmentから呼ぶためにEvent名をつけました。
あとはText ResponseをGoogle Homeらしく変更しただけです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/db0dc39f-426b-3a57-8656-0acd80a22394.png)

### GetRecordsByDate

メインの機能です。
「`@sys.date`の`@object`を`@want-to-know`」のように定義しています。
そうすると、あとでFulfillmentでパラメータとして受け取れます。

`@sys.date`は定義済みの日付に対応するエンティティです。「今日」「4日前」「一週間後」「来週の月曜」なども解釈してくれるのでなかなか便利です。
定義済みのエンティティは[System Entities](https://dialogflow.com/docs/reference/system-entities)と言って他にもいろいろあります。

最後に[Fulfillment]>[Use webhook]にチェックを入れます。これでこのintentのときはFulfillmentで処理をするようになります。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/ece27a95-d620-a7b5-5759-36b4be792258.png)
![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/77225ae4-59b2-7019-bfb8-856d95dbfe5e.png)

### End

`@end`エンティティのときに呼ばれるようにします。
[Google Assistant]>[End conversation]にチェックを入れると、このインテントが処理されると会話を終了するようになります。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/46ddebde-da26-20d1-54cd-e79369a6da69.png)
![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/ec5887c7-76f3-1182-2716-38448eb39882.png)

## Fulfillment

楽なのでInline EditorでCloud Functions for Firebaseを使います。
今回はSalesforceのREST APIを呼びます。無料プランだとGoogle外のドメインにリクエストを投げられませんので、従量課金のBlazeプランに変更する必要があります（[Firebaseの価格表](https://firebase.google.com/pricing/)）。

### package.json

いつもお世話になっている[JSforce](https://jsforce.github.io/)を追加します。

```
    "jsforce": "^1.8.0"
```

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/0ea7e044-c604-2711-3aff-0482e1a5a208.png)

### index.js

以下のようにしました。
基本的にはInline Editorを有効にしたときに生成されるデフォルトコードをベースに修正しています。Dialogflow's V2 APIはまだ`actions-on-google`のnpmが対応していなそうだったので、v2のコードはごっそり削除しています。

```js:index.js
'use strict';

const functions = require('firebase-functions'); // Cloud Functions for Firebase library
const DialogflowApp = require('actions-on-google').DialogflowApp; // Google Assistant helper library
const jsforce = require('jsforce');
exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
  console.log('Dialogflow Request headers: ' + JSON.stringify(request.headers));
  console.log('Dialogflow Request body: ' + JSON.stringify(request.body));
  processV1Request(request, response);
});
/*
* Function to handle v1 webhook requests from Dialogflow
*/
function processV1Request (request, response) {
  let action = request.body.result.action; // https://dialogflow.com/docs/actions-and-parameters
  let parameters = request.body.result.parameters; // https://dialogflow.com/docs/actions-and-parameters
  let inputContexts = request.body.result.contexts; // https://dialogflow.com/docs/contexts
  let requestSource = (request.body.originalRequest) ? request.body.originalRequest.source : undefined;
  const app = new DialogflowApp({request: request, response: response});
  // Create handlers for Dialogflow actions as well as a 'default' handler
  const actionHandlers = {
    'input.get_records_by_date': () => {
      const conn = new jsforce.Connection({
        accessToken: app.getUser().accessToken,
        instanceUrl:'https://ap3.salesforce.com/'
      });
      const date = parameters.date;
      const object = parameters.object;
      const objectDef = {
        event: {
          label: '予定',
          query: `SELECT Subject, FORMAT(StartDateTime) 
          FROM Event USING SCOPE Mine 
          WHERE ActivityDate = ${date}
          ORDER BY StartDateTime ASC LIMIT 10`,
        },
        task: {
          label: 'タスク',
          query: `SELECT Subject FROM Task USING SCOPE Mine 
          WHERE (ActivityDate < ${date} OR ActivityDate = null)
          AND IsClosed = false
          LIMIT 10`,
        }
      };
      const def = objectDef[object];
      conn.query(def.query)
      .then((result) => {
        console.log(result);
        let res = '<speak>';
        if (result.records.length === 0) {
          res += `<p>${def.label}はありません</p>
          <break time="0.5s" />
          <p>今日もがんばりましょう！</p>
          </speak>`;
        } else {
          result.records.forEach((record) => {
            res += '<p>';
            if (record.StartDateTime)
              res += `
${record.StartDateTime.substr(11, 5)}
`;
            res += `
${record.Subject}
</p>`;
          });
          res += `<break time="0.5s" />
          <p>以上です。</p>
          <p>今日もがんばりましょう！</p>
          </speak>`;
        }
        console.log(res);
        // Use the Actions on Google lib to respond to Google requests; for other requests use JSON
        app.ask(res);
      })
      .catch((e) => {
        console.error(e);
        app.ask('すみません。失敗してしまいました。');
      });
    },
    // Default handler for unknown or undefined actions
    'default': () => {
      response.send(
        JSON.stringify({
          followupEvent: {
            name: "UNKNOWN"
          }
        })
      );
    }
  };
  // If undefined or unknown action use the default handler
  if (!actionHandlers[action]) {
    action = 'default';
  }
  // Run the proper handler function to handle the request from Dialogflow
  actionHandlers[action]();
}
```

# 動かしてみよう

## シミュレータで動かす

このように動きます。
なお、いつの間にかアプリ名を「Salesforceアプリ」に変更しています。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/338b3822-0429-73c3-d65c-ca03da798c69.png)

## Google Homeで動かす

実機だとこんな風に動きます。**動画**です。画像をクリックするとYouTubeに飛びます。

[![image.png](https://qiita-image-store.s3.amazonaws.com/0/27267/d7452744-dc8f-2d86-9f1a-1b3a66daab5f.png)](http://www.youtube.com/watch?v=9JGGJpPEsD4)
http://www.youtube.com/watch?v=9JGGJpPEsD4

インコは黙ってもらうことができませんでした。

なお、Google Homeは6人まで声を識別するので、それぞれ別のSalesforceアカウントに紐付けることができるはずです（試してないけど）。

# 問題点

## SalesforceのインスタンスURLをハードコードしてしまっている

index.jsに一箇所SalesforceのインスタンスURLだけハードコードしてしまっています。
普通のサービスだとAPIエンドポイントが一つなのですが、Salesforceは組織によってAPIエンドポイントが変わります。
本来はアクセストークン取得リクエストのレスポンスで取得できるのですが、Account Linkingの中で自動的に処理してくれてしまっていて、それを取り出す方法が見つかりませんでした。

```js
      const conn = new jsforce.Connection({
        accessToken: app.getUser().accessToken,
        instanceUrl:'https://ap3.salesforce.com/'
      });
```

## アクセストークンの更新が動かない

実用には致命的なのですが、アクセストークンの有効期限（Salesforceの組織で設定）を過ぎると動かなくなってしまいます。Cloud Functionsのログには`INVALID_SESSION_ID: Session expired or invalid`とエラーが出力されています。

認証周りのエラーはどこにもログが出てないようなので何が悪いのかわからないのですが、きっとトークンの更新が動いてないのでしょう。
[Account Linkingの要件](https://developers.google.com/actions/identity/oauth2-code-flow)と[SalesforceのOAuthの仕様](https://developer.salesforce.com/docs/atlas.ja-jp.210.0.api_rest.meta/api_rest/intro_understanding_web_server_oauth_flow.htm)を見比べるとSalesforceは`expires_in`を返していないので、いつアクセストークンの期限が切れるかわからなくてトークンの更新処理をしないのかなあと予想しています。

# おわりに

とりあえずGoogle Homeから音声でSalesforceを扱うことができました。
ただし、アカウント連携はAccount Linkingをそのまま使うのは難しそうなので、ちょっと考える必要がありそうです。誰か方法思いついたら教えてくださいm(_ _)m

そこを除けば、後はもう煮るなり焼くなりするだけで、声で便利にSalesforceを使えてしまうはずです。
新規リード/今月の営業ランキング/承認待ちのワークフローなどの確認、活動記録、Chatter投稿などできたら便利ですかね？
次はもうちょっと実用的なのを考えたいものですね。
あとは予約したけど音沙汰のないEchoも試してみたいところです。数日前に[Alexa for Business](https://aws.amazon.com/jp/blogs/news/launch-announcing-alexa-for-business-using-amazon-alexas-voice-enabled-devices-for-workplaces/)が発表されてSalesforceと公式に連携しちゃうそうですけどね。

# 参考

- [Google Homeアプリをリリースしてみた(実装編) - Qiita](https://qiita.com/miso_develop/items/bc22d55788c025c32c39#entities)
    - 見ていただいてわかる通り記事構成含めて大変参考にさせていただきました。