こんにちは、イシイです。

NLU(自然言語理解)エンジンが含まれているプラットフォームである「Dialogflow」を使って、音声対話形式でGoogleカレンダーで管理している社内会議室の施設予約を行うアプリケーション開発のエントリーの第3回目です。

前々回ではDialogflowの使い方などに触れてシミュレータで動かし、前回は会話部分とは別のビジネスロジックを実装する部分であるフルフィルメントについて触れてみました。今回は実際にGoogle Home MiniからGoogleカレンダーへのイベント登録を行うところまで進めてみます。

Googleカレンダーとの連携

Googleカレンダーとの連携に向けて、いくつかの修正と準備を行っておきます。

Action and parametersの項目追加

Googleカレンダーへのイベント登録を行うにあたって、予定のタイトルも任意で登録できるようにしておきたいところなので、対話で取得する要素に予定のタイトルの項目も追加しておくことにします。

「Hearing Intent」に新しく取得したい要素「予約タイトル」を「Action and parameters」に追加します。REQUIREDにチェックをし、「PARAMETER NAME」に「event-title」、「ENTITY」に「@sys.any」、「VALUE」に「$event-title」、「Define prompts」に「予定の名前は何にしますか?」をそれぞれ設定しました。

thumb_1

Dev EntitiesとTraining phrasesの追加修正

会議室名称の「A1(エーイチ、エーワン)」や「A2(エーニ、エーツー)」などは自然言語としては解釈しにくいため、トレーニングなしで対話の中で解析されるのは難しく、例えば「エーイチ」と発音しても「愛知」と解釈されたり「英知」と解釈されたりしてしまいます。そのため、今回は2つの措置を行ってみました。

1つめは、Dev EntitiesのRoomTypeのsynonym(類義語)に項目を追加しました。追加した項目は「A1」「A1会議室」「A 1(半角スペース挟み)」です。A2会議室の方も同様に追加しています。これは、Actions on Googleのシミュレータで音声入力を試してみたときに、シミュレータ内の吹き出しで大文字で返ってきていたため、この形式を登録しておけば認識するのではないかと思い、項目として追加してみています。

thumb_2

2つめは、「Hearing Intent」のTraining phrasesにDev Entitiesのsynonym(類義語)を追加してみました。Training phrasesで登録したものについては、Dialogflowにて機械学習が自動的に行われて認識精度があがるため、自然言語として解釈されにくい名称を追加しておいてみました。

thumb_3

Google Calendar APIの有効化とクライアントシークレットの準備

Googleカレンダーを参照・更新するにはGoogle Calendar APIを有効化しておく必要がありますので、Google Cloud Platformの該当プロジェクトへ遷移し、「APIとサービス」からGoogle Calendar APIを有効化しておきます。

thumb_3_1

有効化が終わったら、クライアントシークレットのJSONファイルをダウンロードしておきます。

thumb_3_2

フルフィルメントのプログラムを修正

Google Calendar APIのNode.js Quickstartには、認証処理と認証したアカウントの直近10件のカレンダーイベント項目を取得するサンプルが載っていますので、こちらを参考に認証までの流れを組みつつ、イベントの登録方法についてはGoogle Calendar APIのガイドのAdd an eventのところを参照しました。

なお、Node.js Quickstartの方では認証用のclientsecret.jsonを同一サーバー内に置いて、認証ができたら生成したcredentials.jsonを同一サーバーに保存して処理を継続する流れになっているのですが、フルフィルメントのInline Editor内でこれらと同等の処理を行うには別サーバーを用意するなど複雑になっていきそうだったので、今回はあくまでカレンダー連携の部分のみを試したいということもあり、先程取得したclientsecret.jsonを使って別のサーバーで認証まで行ってcredentials.jsonを生成しておき、それぞれの文字列データをそのままプログラム内で使用する簡易的な形に変更してしまっています。

また、認証とカレンダー操作用にgoogleapisモジュールを利用しているので、こちらをpackage.jsonに追加しておきます。以下、これらを踏まえたプログラムです。

'use strict';

require('date-utils');

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

const app = dialogflow({debug: true});

const HEARING_INTENT = 'Hearing Intent';
const ARGUMENT_DATE      = 'date';
const ARGUMENT_STARTTIME = 'start-time';
const ARGUMENT_USETIME   = 'use-time';
const ARGUMENT_USETIME_PARM = { //{"usage-time":{"amount":2,"unit":"時"}}
  AMOUNT: 'amount',
  UNIT: 'unit',
};
const ARGUMENT_ROOMTYPE  = 'room-type';
const ARGUMENT_EVENTNAME = 'event-title';

const roomA1 = 'fork.co.jp_xxxxxx@resource.calendar.google.com'; //A1会議室のカレンダー
const roomA2 = 'fork.co.jp_xxxxxx@resource.calendar.google.com'; //A2会議室のカレンダー

let eventTitle; //カレンダー予約タイトル
let eventRoom; //会議室カレンダー
let eventStartTime; //カレンダー予約開始時間
let eventEndTime; //カレンダー予約終了時間

const {google} = require('googleapis');

const SCOPES = ['https://www.googleapis.com/auth/calendar'];
const CRIENT_SECRET = '{"installed"....}'; //client_secret.jsonの文字列データ
const CREDENTIAL = '{"access_token"....}'; //credentials.jsonの文字列データ

//認証
function authorize(credentials, callback) {
  const {client_secret, client_id, redirect_uris} = credentials.installed;
  let token = {};
  const oAuth2Client = new google.auth.OAuth2(
      client_id, client_secret, redirect_uris[0]);

  try {
    token = CREDENTIAL;
  } catch (err) {
    return;
  }
  oAuth2Client.setCredentials(JSON.parse(token));
  callback(oAuth2Client);
}

//カレンダーイベント登録
function makeEvents(auth) {
    const calendar = google.calendar({version: 'v3', auth});

    let event = {
        'summary': eventTitle,
        'location': '',
        'description': '音声アシスタントからの予約投稿です',
        'start': {
          'dateTime': eventStartTime,
          'timeZone': 'Asia/Tokyo',
        },
        'end': {
          'dateTime': eventEndTime,
          'timeZone': 'Asia/Tokyo',
        },
        'attendees': [
          {'email': eventRoom},
        ],
        'reminders': {
          'useDefault': false,
          'overrides': [
            {'method': 'popup', 'minutes': 10},
          ],
        },
      };

      console.log(event);

      calendar.events.insert({
        auth: auth,
        calendarId: 'primary',
        resource: event,
      }, function(err, event) {
        if (err) {
          console.log('Add event error occurred');
          return;
        }
        console.log('Add event done');
      });
}

app.intent('Hearing Intent', (conv, params)  => {

  console.log('Hearing Intent');
  console.log(params);

  //予約日
  let dDate = new Date(params[ARGUMENT_DATE]);
  let reserveDate = dDate.toFormat('YYYY年MM月DD日');

  //予約開始時間
  let startTime = new Date(params[ARGUMENT_STARTTIME]);
  eventStartTime = new Date(dDate.setHours(startTime.getHours())); //カレンダー登録用
  dDate.setHours(startTime.getHours() + 9); //to JST
  let reserveBeginTime = dDate.toFormat('HH24時MI分');

  //利用時間
  let useTimeAmount = params[ARGUMENT_USETIME][ARGUMENT_USETIME_PARM.AMOUNT];
  let useTimeUnit   = params[ARGUMENT_USETIME][ARGUMENT_USETIME_PARM.UNIT];
  let useTime = useTimeAmount + useTimeUnit;

  //予約終了時間
  let endTime = dDate;

  switch (useTimeUnit) { //@sys.durationのunitに応じて処理をわける。日は無視して時と分のみだけの対応にしておく
    case '時':
      endTime.setHours(dDate.getHours() + Number(useTimeAmount));
      break;
    case '分':
      endTime.setMinutes(dDate.getMinutes() + Number(useTimeAmount));
      break;
    default:  
      endTime.setHours(dDate.getHours() + Number(useTimeAmount));
      break;
  }

  let reserveEndTime = endTime.toFormat('HH24時MI分');

  eventEndTime = new Date(endTime.setHours(endTime.getHours() - 9)); //カレンダー登録用
  
  //予約会議室
  let roomType = params[ARGUMENT_ROOMTYPE];

  switch (roomType) { //カレンダー登録用
    case 'A1会議室':
      eventRoom = roomA1; 
      break;
    case 'A2会議室':
      eventRoom = roomA2; 
      break;
    default:  
      break;
  }

  //予約タイトル
  eventTitle = params[ARGUMENT_EVENTNAME];

  //認証とカレンダーイベント登録
  try {
    const content = CRIENT_SECRET;
    authorize(JSON.parse(content), makeEvents);
  } catch (err) {
    return console.log('Authorize error occurred');
  }

  conv.close(`${reserveDate}  ${reserveBeginTime} から ${reserveEndTime} までの ${useTime}間、${eventTitle} として ${roomType} を予約しました。`);    

});

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

Google Home Miniで動作させてみる

プログラムの修正まで終わったので、実際にGoogle Home Miniを使って実機テストをしてみたいのですが、実はシミュレーターのデバイスアイコンにカーソルをあわせると、開発アカウントと同一アカウントでログインしている端末があればそのままその実機を使ってテストできることがわかります。

thumb_4

それでは、実際に動かしてみます。

音声対話を通じて、Googleカレンダーの方に予定登録される動作が確認できました。

検索ページに表示されるように設定

アプリケーション開発や実機テストなどを行うにあたって直接的には関係ないのですが、Googleアシスタントの機能検索を行ったときに、申請・リリースするアプリケーションがどのように表示されるのかも見ておきたいので、最後にこの辺りの設定も触っておいてみたいと思います。

シミュレータを動かしたときに開いたActions on Googleのサイトに遷移し、OverviewページのGet ready for deploymentのセクションを開くと、「Enter information required for listing your Action in the Actions directory」とあるので、こちらからActions Directory(=機能検索のページ)に表示される設定を行っていきます。

thumb_5

まずはDescriptionセクションを開き、アプリケーションの説明を入力しておきます。

thumb_6

Sample invocationsセクションでは、Googleアシスタントから拡張としてアプリケーションを呼び出すときのフレーズを記載しますが、こちらは元から入っている呼び出し名称をそのまま使うことにします。

thumb_7

Imagesセクションでは、アプリケーション用の画像をアップロードします。今回はいったん自社のロゴをアップロードしておきます。

thumb_8

Contact detailsセクションでは、問い合わせなどが発生したときに受け付けるデベロッパー名とEmailアドレスを記載します。なお、デベロッパー名は任意項目となります。

thumb_9

Privacy and consentセクションでは、プライバシーポリシーや利用規約が記載されているURLを記載します。なお、利用規約のURLについては任意項目となります。

thumb_10

Additional Informationセクションでは、アプリケーションのカテゴリーや年齢制限に関するもの、テスト方法や取引などを伴うものかなどの項目を記載します。なお、テスト方法については任意項目となります。

thumb_11

以上の項目が入力し終わったらSAVEを行っておきます。SAVEが完了した後、OverviewページのGet ready for deploymentのセクションに戻ってみると、「Enter information required for listing your Action in the Actions directory」のところにチェックマークが付き、「You are all set.」と書かれていることが確認できます。

thumb_12

これで設定が完了しているので、数時間するとGoogleのサーバー側に浸透されて、先程の検索ページより表示が確認できます。なお、実際に申請・リリースまではしていないので、開発アカウントでログインしてるときのみ、こちらのアプリケーションを検索表示することができます。

こちらが検索結果画面になります。

thumb_13

こちらがアプリケーションの詳細画面になります。

thumb_14

今後の課題

vol.1〜vol.3までを経て、Dialogflowを使った音声対話形式のアプリケーションからGoogleカレンダーの予定登録を行うことができましたが、実際に運用できるようにするためには他にもやるべきことはいくつかあり、

  • クライアントシークレットの情報や認証情報を固定の文字列にしてしまっているので、任意アカウントのオンライン認証型にする等の対応

  • イベント登録時に予定重複するなどの事象が発生したときの対応

  • 実行のタイミングによってうまく日付が取得できないときがあるので、その対応

  • アプリケーション側がうまく聞き取れなかった場合、際限なく何度も繰り返して聞いてしまうので、Actions on GoogleのガイドのNo-match repromptingにあるように、何回か聞き返しが発生したら処理を中断する等の対応

などが必要になってきそうですが、本エントリーでは音声入力からGoogleカレンダー登録を行うところまでを本題としていたため、この辺りについてはまた別の機会にやってみたいと思います。