Webhook を使用してフルフィルメントを作成する

Dialogflow において、フルフィルメントは、ユーザーのリクエストを解決できるサービス、アプリ、フィード、会話などのロジックを指します。今回の例では、インテント「Make Appointment」により指定された時間と日付の情報を元に、バイクショップが予約をスケジュールできるフルフィルメントが必要です。

この設定では、インテントから時間と日付のパラメータを受信できるバックエンド サービスとして Webhook を使用し、API で Google カレンダーにイベントを作成します。このためには、次の 2 つのタスクを実行する必要があります。

  • Google Calendar API の認証情報を取得する。
  • 新しいカレンダーを作成して Webhook でコードを構成する。

インライン エディタで Webhook を作成する

Dialogflow にはコンソール内にインライン エディタがあり、NodeJS コードを直接記述して、その後 Firebase 上の Webhook としてデプロイできます。

Dialogflow のインライン エディタを使用して Webhook を作成する方法は次のとおりです。

  1. [Make Appointment] インテントをクリックします。
  2. [Fulfillment] セクションで、[Enable Webhook call for this intent] ボタンをオンにします。
  3. [SAVE] をクリックします。
  4. ナビゲーション バーの [Fulfillment] タブをクリックして、フルフィルメントのページに移動します。
  5. インライン エディタのボタンを [ENABLED] に切り替えます。
  6. インライン エディタの [package.json] タブ内の既存のコンテンツを削除します。
  7. 以下の JSON コンテンツをコピーして、インライン エディタの [package.json] タブに貼り付けます。

    {
      "name": "DialogflowFirebaseWebhook",
      "description": "Firebase Webhook dependencies for a Dialogflow agent.",
      "version": "0.0.1",
      "private": true,
      "license": "Apache Version 2.0",
      "author": "Google Inc.",
      "engines": {
        "node": "6"
      },
      "scripts": {
        "lint": "semistandard --fix \"**/*.js\"",
        "start": "firebase deploy --only functions",
        "deploy": "firebase deploy --only functions"
      },
      "dependencies": {
        "firebase-functions": "^2.0.2",
        "firebase-admin": "^5.13.1",
        "googleapis": "^27.0.0",
        "actions-on-google": "2.2.0",
        "dialogflow-fulfillment": "0.5.0"
      }
    }
    
  8. インライン エディタの [index.js] タブ内の既存のコードを削除します。

  9. 以下のコードをコピーして、インライン エディタの [index.js] タブに貼り付けます。

    /**
     * Copyright 2017 Google Inc. All Rights Reserved.
     *
     * Licensed under the Apache License, Version 2.0 (the "License");
     * you may not use this file except in compliance with the License.
     * You may obtain a copy of the License at
     *
     *      http://www.apache.org/licenses/LICENSE-2.0
     *
     * Unless required by applicable law or agreed to in writing, software
     * distributed under the License is distributed on an "AS IS" BASIS,
     * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     * See the License for the specific language governing permissions and
     * limitations under the License.
     */
    
    'use strict';
    
    const functions = require('firebase-functions');
    const {google} = require('googleapis');
    const {WebhookClient} = require('dialogflow-fulfillment');
    
    // Enter your calendar ID and service account JSON below.
    const calendarId = '<INSERT CALENDAR ID HERE>'; // Example: 6ujc6j6rgfk02cp02vg6h38cs0@group.calendar.google.com
    const serviceAccount = {}; // The JSON object looks like: { "type": "service_account", ... }
    
    // Set up Google Calendar service account credentials
    const serviceAccountAuth = new google.auth.JWT({
      email: serviceAccount.client_email,
      key: serviceAccount.private_key,
      scopes: 'https://www.googleapis.com/auth/calendar'
    });
    
    const calendar = google.calendar('v3');
    process.env.DEBUG = 'dialogflow:*'; // It enables lib debugging statements
    
    const timeZone = 'America/Los_Angeles';  // Change it to your time zone
    const timeZoneOffset = '-07:00';         // Change it to your time zone offset
    
    exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
      const agent = new WebhookClient({ request, response });
    
      function makeAppointment (agent) {
        // Use the Dialogflow's date and time parameters to create Javascript Date instances, 'dateTimeStart' and 'dateTimeEnd',
        // which are used to specify the appointment's time.
        const appointmentDuration = 1;// Define the length of the appointment to be one hour.
        const dateTimeStart = convertParametersDate(agent.parameters.date, agent.parameters.time);
        const dateTimeEnd = addHours(dateTimeStart, appointmentDuration);
        const appointmentTimeString = getLocaleTimeString(dateTimeStart);
        const appointmentDateString = getLocaleDateString(dateTimeStart);
        // Check the availability of the time slot and set up an appointment if the time slot is available on the calendar
        return createCalendarEvent(dateTimeStart, dateTimeEnd).then(() => {
          agent.add(`Got it. I have your appointment scheduled on ${appointmentDateString} at ${appointmentTimeString}. See you soon. Good-bye.`);
        }).catch(() => {
          agent.add(`Sorry, we're booked on ${appointmentDateString} at ${appointmentTimeString}. Is there anything else I can do for you?`);
        });
      }
      let intentMap = new Map();
      intentMap.set('Make Appointment', makeAppointment);  // It maps the intent 'Make Appointment' to the function 'makeAppointment()'
      agent.handleRequest(intentMap);
    });
    
    function createCalendarEvent (dateTimeStart, dateTimeEnd) {
      return new Promise((resolve, reject) => {
        calendar.events.list({  // List all events in the specified time period
          auth: serviceAccountAuth,
          calendarId: calendarId,
          timeMin: dateTimeStart.toISOString(),
          timeMax: dateTimeEnd.toISOString()
        }, (err, calendarResponse) => {
          // Check if there exists any event on the calendar given the specified the time period
          if (err || calendarResponse.data.items.length > 0) {
            reject(err || new Error('Requested time conflicts with another appointment'));
          } else {
            // Create an event for the requested time period
            calendar.events.insert({ auth: serviceAccountAuth,
              calendarId: calendarId,
              resource: {summary: 'Bike Appointment',
                start: {dateTime: dateTimeStart},
                end: {dateTime: dateTimeEnd}}
            }, (err, event) => {
              err ? reject(err) : resolve(event);
            }
            );
          }
        });
      });
    }
    
    // A helper function that receives Dialogflow's 'date' and 'time' parameters and creates a Date instance.
    function convertParametersDate(date, time){
      return new Date(Date.parse(date.split('T')[0] + 'T' + time.split('T')[1].split('-')[0] + timeZoneOffset));
    }
    
    // A helper function that adds the integer value of 'hoursToAdd' to the Date instance 'dateObj' and returns a new Data instance.
    function addHours(dateObj, hoursToAdd){
      return new Date(new Date(dateObj).setHours(dateObj.getHours() + hoursToAdd));
    }
    
    // A helper function that converts the Date instance 'dateObj' into a string that represents this time in English.
    function getLocaleTimeString(dateObj){
      return dateObj.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true, timeZone: timeZone });
    }
    
    // A helper function that converts the Date instance 'dateObj' into a string that represents this date in English.
    function getLocaleDateString(dateObj){
      return dateObj.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', timeZone: timeZone });
    }
    
  10. [DEPLOY] をクリックします。

    図 7:Webhook 関数 makeAppointment() への接続を示すフローチャート。

これで、インテント「Make Appointment」が Webhook 内の関数 makeAppointment() に接続されました。Webhook の次のコードを見てみましょう。

exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
  const agent = new WebhookClient({ request, response });

  function makeAppointment (agent) {
    // Calculate appointment start and end datetimes (end = +1hr from start)
    const dateTimeStart = convertParametersDate(agent.parameters.date, agent.parameters.time);
    const dateTimeEnd = addHours(dateTimeStart, 1);
    const appointmentTimeString = getLocaleTimeString(dateTimeStart);
    const appointmentDateString = getLocaleDateString(dateTimeStart);
    // Check the availability of the time, and make an appointment if there is time on the calendar
    return createCalendarEvent(dateTimeStart, dateTimeEnd).then(() => {
      agent.add(`Got it. I have your appointment scheduled on ${appointmentDateString} at ${appointmentTimeString}. See you soon. Good-bye.`);
    }).catch(() => {
      agent.add(`Sorry, we're booked on ${appointmentDateString} at ${appointmentTimeString}. Is there anything else I can do for you?`);
    });
  }
  let intentMap = new Map();
  intentMap.set('Make Appointment', makeAppointment);
  agent.handleRequest(intentMap);
});

上記のコードには次の行があります。

intentMap.set('Make Appointment', makeAppointment);

関数 set() は、Map オブジェクトである intentMap で呼び出されています。この関数は、インテントをコード内の特定の関数にリンクします。この場合、呼び出しにより、インテント「Make Appointment」と関数 makeAppointment() の間のマッピングが確立されます。

関数 makeAppointment(agent){} は、agent.parameters.dateagent.parameters.time を介して入力オブジェクト agent から日付と時間のパラメータ値を読み取ります。日付と時刻の値を解析および書式設定した後、関数はカスタム関数 createCalendarEvent() を呼び出します。これは、Google カレンダーへの API 呼び出しを行い、カレンダーに予定を作成します。

最後に、関数 agent.add() を使用して、カスタマイズされた文字列をレスポンスとしてユーザーに配信します。Dialogflow コンソールを使用してレスポンスを提供するのとは異なり、Webhook を使用するとコードのロジックを使用して非常に動的なレスポンスを構築できます。たとえば、エージェントが予定を正常にスケジュールすると、次のレスポンスで返信が行われます。

Got it. I have your appointment scheduled on ${appointmentDateString} at ${appointmentTimeString}. See you soon. Good-bye.

また、エージェントが指定された日時に予約できなかった場合、次のレスポンスが返されます。

Sorry, we're booked on ${appointmentDateString} at ${appointmentTimeString}. Is there anything else I can do for you?

現時点では、コードが Google Calendar API にアクセスできないため、コードを正しくテストできません。Google Calendar API の設定をカスタマイズするには、コードで次の変数を使用します。

const calendarId = '<INSERT CALENDAR ID HERE>'; // Example: 6ujc6j6rgfk02cp02vg6h38cs0@group.calendar.google.com
const serviceAccount = {}; // The JSON object looks like: { "type": "service_account", ... }

次に、コードが Google Calendar API にアクセスするためのコードを構成します。

Google Calendar API の認証情報を取得する

Google Calendar API の認証情報を取得する方法は次のとおりです。

  1. Dialogflow のナビゲーション バーで、エージェントの名前の横にある設定ボタン ⚙ をクリックします。
  2. [GOOGLE PROJECT] の表で、プロジェクト ID のリンクをクリックして、Google Cloud Platform Console を開きます。
  3. Google Cloud Platform Console でメニューボタン ☰ をクリックして、[API とサービス] > [ライブラリ] の順に選択します。
  4. Google Calendar API カードをクリックします。

  5. Google Calendar API ページで [有効にする] をクリックします。

  6. ナビゲーション バーで [認証情報] をクリックします。

  7. [認証情報を作成] をクリックし、プルダウン メニューから項目 [サービス アカウント キー] を選択します。

  8. [サービス アカウント] プルダウン メニューをクリックし、項目 [新しいサービス アカウント] を選択します。

  9. [サービス アカウント名] 入力フィールドに、「bike-shop-calendar」と入力します。

  10. [ロール] フィールドで、プルダウン メニューから [プロジェクト] > [オーナー] を選択します。

  11. [作成] をクリックします。サービス アカウント キーが含まれた JSON ファイルがダウンロードされます。

新しいカレンダーを作成して Webhook でコードを構成する

次に、バイクショップが予定を追跡するための新しいカレンダーを作成する必要があります。ダウンロードした JSON ファイルの情報を使用して、エージェントの Webhook コードを新しい Google カレンダーと統合します。次の手順に進む前に、JSON ファイルの内容を表示およびコピーできることを確認してください。

新しいカレンダーを作成して統合を完了するには、次の手順に従います。

  1. Google カレンダーを開きます。
  2. Google カレンダーのナビゲーション バーで、[友だちのカレンダーを追加] 入力フィールドの横にある + ボタンをクリックします。
  3. プルダウン メニューから [新しいカレンダー] を選択します。
  4. [名前] フィールドに「Mary's Bike Shop」と入力します。
  5. [カレンダーを作成] リンクをクリックします。(新しいカレンダー「Mary's Bike Shop」は、ナビゲーション バーの [マイカレンダーの設定] ウィンドウの下に作成されます。)
  6. [Mary's Bike Shop] をクリックし、[特定のユーザーと共有する] を選択します。
  7. [特定のユーザーと共有する] セクションで、[ユーザーを追加] をクリックします。(画面に [特定のユーザーと共有する] ポップアップ ウィンドウが表示されます。)
  8. 先ほどダウンロードした JSON ファイルを開き、client_email フィールド内のメールアドレスを引用符なしでコピーします。

    {
      "type": "service_account",
      "project_id": "marysbikeshop-...",
      "private_key_id": "...",
      "private_key": "...",
      "client_email": "bike-shop-calendar@<project-id>.iam.gserviceaccount.com",
      "client_id": "...",
      "auth_uri": "https://accounts.google.com/o/oauth2/auth",
      "token_uri": "https://accounts.google.com/o/oauth2/token",
      "auth_provider_x509_cert_url": "https://www.googleapis.com...",
      "client_x509_cert_url": "https://www.googleapis.com/robot/v..."
    }
    
  9. [特定のユーザーと共有する] ポップアップ ウィンドウで、[メールアドレスまたは名前を追加] 入力フィールドの client_email のメールアドレスを貼り付けます。

  10. [権限] プルダウン メニューで、項目 [予定の変更権限] を選択します。

  11. [送信] をクリックします。

  12. [カレンダーの統合] までスクロールして、[カレンダー ID] の内容をコピーします。

  13. Dialogflow コンソールで、エージェントの [Fulfillment] ページに移動します。

  14. インライン エディタの [index.js] タブで、変数 calendarId を見つけます。

    const calendarId = '<INSERT CALENDAR ID HERE>';
    
  15. 次のコードに示すように、[カレンダー ID] の内容を貼り付けて <INSERT CALENDAR ID HERE> 部分を置き換えます。

    const calendarId = 'fh5kgikn3t4vvmc73423875rjc@group.calendar.google.com';
    
  16. インライン エディタの [index.js] タブで、変数 serviceAccount を見つけます。

    const serviceAccount = {};
    
  17. 先ほどダウンロードした JSON ファイルに戻って、最も外側の中かっこ({})を含む内容全体をコピーします。

  18. 次のコード スニペットに示すように、コンテンツを貼り付けて serviceAccount の値を置き換えます。

    const serviceAccount = {
      "type": "service_account",
      "project_id": "marysbikeshop-...",
      "private_key_id": "...",
      "private_key": "...",
      "client_email": "bike-shop-calendar@<project-id>.iam.gserviceaccount.com",
      "client_id": "...",
      "auth_uri": "https://accounts.google.com/o/oauth2/auth",
      "token_uri": "https://accounts.google.com/o/oauth2/token",
      "auth_provider_x509_cert_url": "https://www.googleapis.com...",
      "client_x509_cert_url": "https://www.googleapis.com/robot/v..."
    };
    
  19. [DEPLOY] をクリックします。

  20. エージェントをテストして、新しい予定がカレンダーに正しく作成されたことを確認します。

さらなる機能の強化

現在、エージェントがサービスを提供できるのはユーザーが機能を把握している場合のみです。ユーザーがスクリプト化された対話以外の内容を指定すると、対話をうまく処理できません。対話型インターフェースでは、エージェントが予期せぬユーザーの対話入力から回復できるようにすることが重要です。エージェントが対話の流れを効果的に制御できるようになる、Dialogflow の強力な機能であるコンテキストを確認することをおすすめします。