プログラマーのメモ書き

伊勢在住のプログラマーが気になることを気ままにメモったブログです

Dialogflow のチュートリアルを試してみた : context と fulfillment (1/2)

LINE WORKS で予約投稿ボットを作成する件ですが、ボットと対話して相手先や投稿時間を指定できるようにしたいと思います。 ですが、AWS が用意しているチャットボット(など)のサービス Amazon Lex がいまだ(2019/6/18現在)に日本語未対応なので、他のサービスを使おうと思います。

一番下の参考に挙げたように、調べてみるといろいろなサービスが世の中にはあるようです。正直、何を使って実現するか迷うところです。
いろいろと調べて Dialogflow と Watson の2択まで絞ったのち、 両方簡単に試してみて、Dialogflow のほうが自分には合ってるっぽい印象だったので、Dialogflow を使ってみることに決めました。
無料で簡単に試せるのってありがたいですね。 Google, IBM さんともに感謝です。

まずは、 LINE WORKS とつなげて使う前に、 Dialogflow そのものをいろいろ試したので、それについてメモっときます。

チュートリアルについて

いろいろと、 Dialogflow サンプルもありますが、ここでは Dialogflow 公式にあるチュートリアルを試してみます。

Tutorials & samples  |  Dialogflow ES  |  Google Cloud

Tutorials & samples  |  Dialogflow ES  |  Google Cloud

このチュートリアルでは、

  • パラメータの問い合わせ(Slot filling)
  • パラメータが埋まった際の fulfillment の呼び出し
  • コンテキスト(context)の切り替え

などを扱っています。また、チュートリアルが実現している機能として、

  • 予約の確認
  • 予約が取れなかった際の提案

をコンテキストを使い実現している当たりが参考になるかと思い選びました。

Webhook と Inline Editor

Dialogflow では、パラメータがすべてそろったら、指定した URLを呼び出して、何かを実行させたい場合、 fulfillment という機能が使えます(詳しくは、公式ドキュメントをお読みください)。

チュートリアルでは fulfillment の部分( Create a webhook with the inline editor および Build an agent from scratch using best practices (Advanced concepts) に記載されている処理)については、 Inline Editor (+ Dialogflow fulfillment library) で実現していますが、これを Webhook と JSON で書き換えてみました。

Inline Editor も無料で使えるのになんでわざわざWebhook を使うの?という理由の一つに、 Dialogflow の inline editor は無料プランでは外部API(外部のURLへのアクセス)が使えない、というのがあります。

【Dialogflowメモ】 Dialogflowを用いた外部APIの利用は無料ではできない!? | ゆたかみわーく

最終的には、 fulfuillment が呼ばれたときに、外部APIを使って情報を取得し、それを返そうと思っているので、このような構成で試すことにしました。

Dialogflow の Agent の作成

まずは Dialoflow の Agent を作成しないと始まりません。

適当な Agent を作成します。 Agent 作成時に GCP のプロジェクトをどうするか?という選択肢があるので、

f:id:junichim:20190619100925p:plain

デフォルトの 『Create a new Google project』を選んでおきます。あと、 default language は日本語にしときます。この default language は変更できないようなのでご注意ください(対応言語を追加することはできます)。

Agent ができたら、チュートリアルを先頭から実施していきます。

とりあえず、 fulfillment の手前 Create an intent with parameters まで実施します。 ここまで設定して、コンソール上(Dialogflow のシミュレータ)で思うような動作になっていることを確認しておきます。

問題がなければ、いよいよ fulfillment を試していきます。

fulfillment を設定

fulfillment は intent 単位で有効・無効を設定します。なので、ある Intent に対してはパラメータがそろったら fulfillment を呼び出すが、別の Intent に対しては呼ばない、といった設定が可能です。

Intent の設定画面の下のほうに、Fulfillment というところがあるので、そこを開いて、『Enable webhook call for this intent』を有効にします。

f:id:junichim:20190625095703p:plain

設定が終わったら Intent を保存しておきます。

次に、左側のメニューより Fulfillment を選び、Webhook か Inline Editor のどちらか一方を有効にします(どちらか一方しか有効にできません)。

f:id:junichim:20190625095937p:plain

ちなみに、Webhook は指定した URL (https) を POST で呼び出します(なお、 webhook の呼び出しの詳細は公式ドキュメントのここに載ってます)。
Inline Editor で定義した処理は GCP の Cloud Function で実行されます。

参考にしたこのチュートリアルでは(だけに限らず他の多くのサンプルでも) Inline Editor を使っていることが多い印象です。今回はここを Webhook (AWS Lambda) にしてみます。
ということで、 Webhook を有効にしたら、Lambda (実際には API Gateway )のURLを入力しておきます。

あと、 fulfillment の注意としては、呼び出し先( webhook にしても、Inline Editor にしても)は、Agent に対して一つだけが設定可能です。 つまり、複数のIntent からの fulfillment 呼び出しを有効にした場合、呼び出された側で処理の振り分け(ルーティング?っぽい処理)をしてやる必要があります。

呼び出し元(Intent)を区別するために必要な Intent 名などの情報は パラメータとして受け渡されるのでそれを利用して処理を記述します。

webhook の実装

Webhook の実装の前に残念なお知らせがあります。
実は、 Fulfillment 処理を簡単に書くための Node.js 用のライブラリがあります。

github.com

Dialogflow が Integration で対応している8つのチャットプラットフォームと Dialogflow 上のシミュレータに対応しているそうです。

Lambda 上の Node.js でも、これを使えば簡単に実装できそうに思えたのですが、このライブラリのメインで使うオブジェクトである WebhookClient オブジェクトを生成するために必要な request / response オブジェクトが Express のhttpオブジェクトとなってました。

Lambda の場合、どうやってこれに対応すればよいのか改めて調べるのもちょっと面倒だなと思ってしまいました(aws-serverless-express とか使うのか?)。

なので、今回はこのライブラリを使うのをあきらめました。 で、直接レスポンスで返す JSON を編集する方法で対応しました。ま、試用ですしね、今回はこれでいいかな、と。

以下では、JSONで処理する際のポイントについて説明していきます。最終的なコードは GitHubのリポジトリ をご覧ください。
なお、Dialogflow の試用が目的なので、予約が空いているかの確認や実際の予約処理は Google Calendar API は使わずに、適当に乱数で成否を返すようにします。

リクエストとレスポンス

API Gateway 経由で Lambda が受け取るった POST リクエストは、 event.body にJSON 文字列として渡されます。なので、

    let body = JSON.parse(event.body);

として、JSONオブジェクトとして取得しておきます。

また、戻り値も JSON オブジェクトで返すので

// 戻り値
let response;

function createResponse() {
  let response = {
    fulfillmentText: "This is a text response",
    fulfillmentMessages: [],
    source: "example.com",
    payload: {},
    outputContexts: [
        {
            name: "projects/${PROJECT_ID}/agent/sessions/${SESSION_ID}/contexts/context name",
            lifespanCount: 5,
            parameters: {
                param: "param value"
            }
        }
    ],
    followupEventInput: {}
  };
  return response;
}

として、あらかじめレスポンスオブジェクトを定義したうえで、

    response = createResponse();
    requestBody = body;

    // レスポンスにコンテキストを(ディープ)コピー
    response.outputContexts = JSON.parse(JSON.stringify(body.queryResult.outputContexts));

のように、outputContexts をリクエスト body からコピー(ディープコピー)しておきます。

もし、 fulfillment 処理において outputContexts を変更する必要がなければ、リクエストと同じ値を戻し、 必要があれば、後続する処理で書き換えるようにするためです。

なお、これらのリクエスト/レスポンスの JSON の定義は、公式ドキュメントに記載されているので、そちらをご覧ください(上記のレスポンス JSON は、このページのレスポンスから fulfillmentMessages と payload を削除して作成しました)。

Lambda での実装時の注意

あと、 Lambda を使う上での注意として、Lambda は関数を実行する際のインスタンスを再利用します。

このため、下記にあるように、再利用時にモジュールレベルの変数が使いまわされる場合があります(初期化状態が維持されます)。

毎回、初期化されるのではないので、実装時は注意が必要ですね(今回、実装しててトラブるまで気づいてませんでした・・・)。

intent の振り分け

チュートリアルの Inline Editor の例の場合、

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

  function makeAppointment (agent) {
    ・・・(中略)・・・
    });
  }
  let intentMap = new Map();
  intentMap.set('Make Appointment', makeAppointment);  // It maps the intent 'Make Appointment' to the function 'makeAppointment()'
  agent.handleRequest(intentMap);
});

のように WebhookClient を作成して、 handleRequest を呼べば、 intentMap に従って、処理を振り分けてくれます。

JSON で処理する場合もやり方をまねします。 webhook 関数を呼び出した元の intent は リクエストオブジェクトの queryResult.intent.displayName に書かれています。

なので、上記と同様に、 Intent 名に応じて呼び出す関数を map オブジェクトに登録します。

    // intent と対応するハンドラ関数のマップを生成
    let intentMap = new Map();
    intentMap.set('reservation', checkAppointment);
    intentMap.set('reservation - yes', makeAppointment);
    intentMap.set('reservation - suggestion - yes', suggestAppointment);

※ Intent 名が チュートリアルは 『Make Appointment』となっているところを 『reservation』に変えていますが、特に理由があるわけではなく単なる気分です。

実際の振り分け処理用の関数はこんな感じになります。

async function handleRequest(intentMap, intent) {
  const intentName = intent.displayName;

  // 呼び出し
  if (intentMap.has(intentName)) {
    return intentMap.get(intent.displayName)();
  }

  console.log("no intent handler, intent: " + intentName);
  throw new Error("no intent hander");
}

この振り分け関数を下記のように呼び出せば、intentに応じた処理が呼び出せます。

    // intent に応じて処理
    let ret;
    try {
      ret = await handleRequest(intentMap, intent);
    } catch(e) {
      console.error("error occured: " + JSON.stringify(e));
      throw e;
    }

Dialogflow からの応答

チュートリアルの MakeAppointment 関数を見ると、

  function makeAppointment (agent) {
    ・・・(中略)・・・
    // 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?`);
    });
  }

のように add メソッドを呼ぶことで対応しています。

JSON であれば、

    response.fulfillmentText = `予約が完了しました。 ${date} 日の ${time} 時に予約をお取りしました。`;

のように、 fulfillmentText に応答分をセットすればOKです。

context の取り出し

次に context を区別するために、contextを取り出す方法です。

チュートリアルの Create a confirmation step を見ると、

  function makeAppointment (agent) {
    // Get the context 'MakeAppointment-followup'
    const context = agent.context.get('makeappointment-followup');

のように、context 名をパラメータにして get メソッドを呼ぶことで context を取得しています。

これを JSON の処理で行うために、 context が受け渡されているフォーマットを確認すると、下記のようにスラッシュ区切りの構造になっています。

    "outputContexts": [
        {
            "name": "projects/${PROJECT_ID}/agent/sessions/${SESSION_ID}/contexts/context name",

なので、 outputContexts の配列を調べ、nameプロパティの最後が求める context 名になっているかを見れば、 context を取得できそうです。

例えば、こんな風にしてみました。

function getContext(response, contextName) {
  for (let context of response.outputContexts) {
    const ary = context.name.split("/");
    if (ary[ary.length-1] === contextName) {
      return context;
    }
  }
  return;
}

呼び出す側は、

async function makeAppointment() {

  console.log("makeAppointment called");

  const context = getContext(response, "reservation-followup");
  if (!context) {
    console.warn("コンテキスト, " + "reservation-followup" + " が見つかりません。");
    throw new Error("no context");
  }

  const date = context.parameters.date;
  const time = context.parameters.time;

  // コンテキストをクリア
  context.lifespanCount = 0;

  // 予約 OK/NG を想定
  const flg = trueOrFalse(1);

  if (flg) {
    response.fulfillmentText = `予約が完了しました。 ${date} 日の ${time} 時に予約をお取りしました。`;
  } else {
    response.fulfillmentText = `すいません。なぜか予約に失敗しました。 ${date} 日の ${time} 時の予約を取り直してください。`;
  }
  return response;
}

のようにして、取得した context およびそれに紐づくパラメータを利用することができます。

なお、ここで一点注意が必要なのが、 Lambda 関数からのレスポンスの JSON に含まれている outputContexts が最終的に Dialogflow に渡され、それが対応する(fulfillmenを呼び出した) Intent の output context になります。

なので、contextを操作する際は、request オブジェクトではなく、 response オブジェクトに対して操作を行っています(最初に outputContexts をディープコピーでレスポンスオブジェクトにコピーしている理由です)。

context の削除

context を削除するには、 上記の例にもあるように、その context の lifespanCount を 0 にした JSON を戻してやればOKです。

context の切り替え

新しい context を追加するためには、新規の context オブジェクトを生成し、 outputContexts 配列に追加すればOKです。 そのうえで、既存の context を削除(lifespanCount を 0 に設定)すれば、切り替えができます。

今回だと、指定日時に予約があるかどうかをチェックする部分で、先に予約があった場合(試しなので乱数で決定してます)、コンテキストを reservation - suggestion に切り替えています。

// intent に応じた処理
async function checkAppointment() {

  console.log("checkAppointment called");

  const context = getContext(response, "reservation-followup");
  if (!context) {
    console.warn("コンテキスト, " + "reservation-followup" + " が見つかりません。");
    throw new Error("no context");
  }
  
  const date = requestBody.queryResult.parameters.date;
  const time = requestBody.queryResult.parameters.time;

  console.log("context before: " + JSON.stringify(response));

  // check OK/NG を想定
  const flg = trueOrFalse(1);

  if (flg) {
    response.fulfillmentText = `了解しました。 ${date} 日の ${time} 時の予約でよろしいでしょうか?`;
  } else {
    response.fulfillmentText = `ごめんなさい。 ${date} 日の ${time} 時は、既に予約が埋まってました。別の日に予約しますか?`;

    // コンテキストをクリア
    context.lifespanCount = 0;

    // suggestionコンテキストに切り替え
    let newContext = JSON.parse(JSON.stringify(context));
    newContext.name = setNewContext(newContext.name, "reservation-suggestion");
    newContext.lifespanCount = 3;
    newContext.parameters = {
      suggested_time: "2019-07-22T13:00:00+9:00"
    };
    console.log("new context: " + JSON.stringify(newContext));
    response.outputContexts.push(newContext);
  }
  
  console.log("context after: " + JSON.stringify(response));

  return response;
}

function setNewContext(oldContextName, newContextName) {
  let ary = oldContextName.split("/");
  ary[ary.length-1] = newContextName;
  return ary.join("/");
}

上記の処理では新しい context を生成する際、 context オブジェクトの name プロパティを正しい形式に合わせるのを簡単にするため、既存の context オブジェクトをコピーし、 context 名だけ変更するようにしています。

ちなみに、contextは複数持てますので、常に一つにする必要はありません(contextの管理は頑張ってやってください)。

なお、intent や context をどのように JSON で扱えばよいかは、

Manage contexts  |  Dialogflow ES  |  Google Cloud

に記載されているので参考にしてください。

実装した結果

チュートリアルの内容を一通り実装したものは、 GitHub のリポジトリ をご覧ください。

Dialogflow の Agent の内容は基本的にチュートリアルと同じです(一部 Intent 名が異なっています)が、これもわかりやすいように、Export した zipファイルも置いておきます。

zip ファイルの使い方は、 公式ドキュメントを見てもらうほうが早いのですが、簡単に書いておくと、

  1. Dialogflow で新規に Agent を作成
  2. Agent の設定画面を開く
  3. zip ファイルを import する

と設定内容が再現できます。ただし、 fulfillment の webhook の URL はダミーになっているので、ご自分のサーバー等の URL を入力してください。

なお、Agent で zip ファイルの設定を import する場合も、 default language は変更されません(追加は可能)ので、ご注意ください。

まとめ

これで Dialogflow を一通り使ってみて、 fulfillment の呼び出し、 context の切り替えも試しました。

ただ、この状態だと

  • context の操作が Dialogflow 側の Intent の設定と fulfillment に分かれている

のが気になります。ちょっと大きな Agent を作ると context の流れが混乱して破綻しそうな雰囲気が漂ってます。

これへの対応方法として Event を使った方法が使えないか試してみようと思います。

参考

ボット開発用のサービス・ツールを紹介してくれているまとめ記事

最初の2つの記事は、いろんなサービス・ツールを4つに分類して紹介してくれてますが、正直、チャット提供企業のAPIとそれ以外の違いはわかるのですが、残り3つの中の中の分類の違いはよく分かんなかったです。いろいろと試すと見えてくるのかもしれませんね。

最後の記事は、サービス・ツールという観点ではなく、対話サービスそのものがどういうものなのか、という点について解説してます。対話サービスを実現するとはどういうことなのかちょっとわかった気がしました。

Dialogflow のサンプル

整理しきれないぐらい大量のサンプルやチュートリアルがありますが、私が参考にしたものを挙げておきます。

Watson のサンプル

3番目のWatson のチュートリアル試してて、挫折しました。もうちょっとやったらピンと来るのかな?

第20回伊勢IT交流会開催しました。

先日、2019年6月15日(土)に、第20回伊勢IT交流会を開催しました。

iseit.connpass.com

前日から当日にかけて、この季節に台風か?と思うぐらいの強風で開催中止もあり得るからな?と気をもんでいたのですが、雨に降られはしたものの無事に開催できました。

当日はこんな感じ。

f:id:junichim:20190617091107j:plain

参加者9名でこじんまりとした会でした。LTは5件あり、興味深い内容が多かったです。

以下、簡単にまとめときます。

LT 1 件目:LINE WORKS で予約投稿ボットを作った話

私自身の発表でした。内容は、こないだここに書いた話なので、割愛します。

blog.mori-soft.com

発表後の質問で、『わざわざ LINE WORKS をなんで使うの?』という意見が印象的でした。

伊勢IT交流会の場でも少し話したのですが、それについて改めてまとめてみました。

個人的には、地方の中小零細企業だと、自分の携帯やスマホがそのまま仕事の連絡先になっているケースが多いのではないかと感じています。特に事務所でPCに向かって仕事をしていない現場作業が多い職種。 一時期(今も?)、 BYOD (Bring your Own Device) って騒がれていたように思いますが、それがもう現実になってるような印象です。
じゃあ、LINE グループでいいんじゃない?となるんですが、従業員同士でも自分のプライベートの連絡先知られたくない、というのは多いと思います(異性同士だとなおさらでしょうね)。 ということから、 LINE WORKS などのように、個人のスマホに入れるけど、つながってるのは仕事の状態のみ、というのが選択肢として出てくるのかな?と思われます。

LT 2 件目:ブログ執筆に取り組んだ結果 [ブログ執筆によるコンテンツSEO効果検証]

shinroh さんからの発表です。

f:id:junichim:20190617091320j:plain

発表資料はこちら。

docs.google.com

最近、独立して事業を始めたそうです。 若いっていいですね! 見習いたいところです。

LT 3 件目:Flutter を語る

tomoaki_kanayama_3 さんからは、Flutter とクロスプラットフォーム開発についての話をいただきました。

f:id:junichim:20190617092115j:plain

資料はこちら。

docs.google.com

Flutter 全然知りませんでしたが、かなり面白そうです。

LT 4 件目:水耕栽培 楽しいよ

moyashi さんからの発表です。

f:id:junichim:20190617091810j:plain

笑顔がにこやかですね。 発表資料はこちら。

www.slideshare.net

自宅で水耕栽培に取り組んでいる話をしていただきました。 水耕栽培はエンジニアに向いているという話でした。

LT 5 件目:WMI をまじめに使ってみた

emotion さんからの発表です。

f:id:junichim:20190617092047j:plain

発表もさることながら、発表前にあったとある愚痴に非常に共感でき、そうそう、と思わずうなずいてしまいました。

WMI 使えばかなりいろんなことができるツールを作れそうですね。MSDNが手放せないような印象でした。 企業内でエンジニアとかしていると管理ツール作れとか言われて、これを駆使することになりそうです。

懇親会

伊勢IT交流会終了後は、ちかくの居酒屋で懇親会でした。 いろいろ食べて話して、写真撮るのを忘れていました。

主催の私がプログラマということもあり、エンジニアの参加が多めですが、伊勢IT交流会自体はエンジニアに限った会ではありません!
なので、興味ある人はぜひお気軽に参加してみてください。
次回は半年後ぐらいに開催する予定でいます。

あと、来月7月13日(土)のOSC名古屋で、『三重勉強会・コミュニティ共同ブース』という形で、県内の他の勉強会・コミュニティさんと一緒にPRしてきます。 OSC名古屋に来場されましたら、お気軽にお声がけください。

LINE WORKS で予約投稿ボットを作成する

訳あって、 LINE WORKS でボットを作ることになりました。今回試すのは、予約投稿を可能にするボットです。

予約投稿といっても、別段難しい話ではなく、

  • ユーザーが LINE WORKS 上で予約投稿ボットに投稿
  • 一定時間後に宛先ユーザーに予約投稿ボットから投稿される

という動作をするものです。いわゆる言ったことをそのまま返す、オウム返しボットの応用になります。

今回は予約投稿ができることを確認するためのサンプルなので、

  • 宛先ユーザーやボットからの送信日時を、ボットとの対話を通じて指定する機能
  • 予約投稿の確認やキャンセル

などは省略しています。投稿した内容を一定の固定時間おいて宛先ユーザー(これも固定)に配信するだけのサンプルになります。

このサンプルを作った顛末を参考までにメモっと来ます。

なお、元ネタとしては、下記のブログで紹介されている記事を参考にさせていただきました。ありがとうございました。

LINE WORKSで初めてのBot開発!(前編) - Qiita

LINE WORKSで初めてのBot開発!(後編) - Qiita

構成

今回作るものの構成はこんな感じです。

f:id:junichim:20190611172647p:plain

cacoo で描きました)

ユーザーが予約投稿ボットに投稿すると、同じ内容を別ユーザーに時間をおいて投稿します。

ボットが通信する送受信サーバーとして、 AWS Lambda を使います。予約投稿が終わるまで Lambda を実行中にするわけにはいかないので、 CloudWatch Events に登録し、指定時間に Lambda を呼び出すようにして、投稿処理を行いたいと思います。

とりあえず思いついたのが上記の構成なので、もっといい方法があるよ、という方、ぜひ教えてください。

準備

まずは、LINE WORKS のボット API を使うための準備をします。LINE WORKS をまだ使ってなければ、無料プランがあるので、申し込みましょう。

ボットAPIを使うための準備

LINE WORKS の申請をしたのが自分自身なら、最初から Developer の権限を持っていると思いますので、 LINE WORKS Developer Console にログインできると思います。

もし、会社や組織等で LINE WORKS を使っており、自分のアカウントに Developer の権限がない場合、Developer Console にログインできません。その場合は、管理者の方に相談して、ログインできる権限をもらってください。

LINE WORKS Developer Console にログインしたら、APIのメニューより、API ID / Server API Consumer Key を発行しておきます。

f:id:junichim:20190612103929p:plain

あと、LINE WORKS のAPIを使うためには、サーバートークンが必要になりますが、これは、サーバーの登録方法により発行方法が異なっています。

サーバーの登録方法は、固定IPとID登録の2種類があります。 今回はサーバーとして Lambda を使うので、固定IPタイプではなく、ID登録タイプを選択します。

ID登録タイプの場合、サーバーの登録(Server List への登録)は、先ほど発行した Server API Consumer Key を登録し、サーバーIDと認証キー(秘密鍵)を発行します。 サーバー名は好きなように指定します。これは自分がサーバーを区別するために使うだけで、認証等では使わないようです。

f:id:junichim:20190612104132p:plain

最終的にサーバートークンは、これらを用いて、JWT (Json Web Toekn) を利用し、専用APIを呼ぶことにより発行しますが、この部分はボットAPI利用時に Lambda 側で行う処理になります。

ということで、Developer Console ではサーバーIDと秘密鍵の発行までやっておけばOKです。 なお、このあたりの一連の手順は、下記公式ドキュメントに詳しく載っていますので、ぜひご覧ください。

API 認証の準備

ボットの登録

次に、LINE WORKS 上にボットを登録します。

LINE WORKS は1契約が1テナントという単位で管理されています(こちらの料金のページにある『Q.ユーザー/メンバーとライセンスの違いを教えてください。』にテナントについての説明があります)。

1テナントがさらに複数のドメインという単位に分かれているそうですが、通常は1テナント1ドメインなので、気にしなくていいようです(グループ企業全体で1テナント、子会社単位で複数ドメインというのがあるそうですが、特殊なケースだそうです。詳しくはこちらの『Botとドメインの関係につきまして』をご覧ください)。

LINE WORKS のボットは、 LINE のボットと異なり、LINE WORKS の管理者(テナントとドメインの管理者)がボットを登録すれば、そのドメインのユーザー全員が使えるようになります(正しくはボットを使うユーザーを管理者が指定でき、最大の範囲が全員になります)。

ということで、最初にテナントに対して登録し、次にドメインに登録します。

テナントに登録

テナントに対するボットの登録は、 LINE WORKS Developer Console でボットを登録すればOKです。 Botメニューを開き、『登録』ボタンを押すと、入力画面が表示されるので、ボット名など必要な項目を入力して、『保存』ボタンを押せば完了です。

f:id:junichim:20190611220805p:plain

なお、この時点では、 Callback URL (ボットがユーザーからの投稿も受け付けた際に呼び出すURL)がまだ未定ですので、Off のままにしておきます(あとから変更できます)。 また、『ボットポリシー』の『トークルームへ招待』は招待不可としておきます(各ユーザーと予約投稿ボットのやり取りになるので)。

ドメインに登録

次に、ボットをドメインに登録します。

LINE WORKS にログインし、管理者画面を開きます。上部のメニューより『サービス』を選択し、左側のメニューより『Bot』を選びます。

f:id:junichim:20190611221009p:plain

追加ボタンを押すと、Developer Console で登録したボットが表示されますので、選択して追加を行ってください。

テナント、ドメインや登録したボットの状態に関する説明は、下記公式ドキュメントをご覧ください。 トークBot APIの概要 - トークBot - LINE WORKS Developers Document

メッセージ受信処理

さて、ここまでくれば準備OKです。まずは、ボットからのメッセージ受信処理を作成します。

ユーザーからの投稿を LINE WORKS ボットが受け取り、その投稿(メッセージ)がボット登録時に指定した Callback URL に POST されます。 なので、受信メッセージサーバーを立てて、POST でメッセージを受け取るようにします。

これは具体的には API Gateway と Lambda で実装します。

Lambda 関数の作成

受信メッセージを処理する Lambda 関数を適当な名前で作成します(今回は reservePost としました)。 ランタイムは、 Node.js 10.x を選んでおきます。

実行ロールは、IAM を使って事前に作成したものを指定します。 例えば、今回は、 下記のように

f:id:junichim:20190611224026p:plain

  • AWSLambdaBasicExecutionRole
  • CloudWatchEventsFullAccess

というポリシーを持つロールを作成して、これを指定しました。

とりあえず、中身は未実装にしておき、API Gateway の設定を先にします。

API Gateway

API Gateway のほうは適当なリソース名を入力し、POSTメソッドを追加します。

f:id:junichim:20190611222830p:plain

統合タイプに Lambda 関数を指定し、『Lambda プロキシ統合』にチェックを入れ、Lambda関数を指定しておきます。

設定ができたら、API のデプロイを行います。デプロイができたら、URLが表示されますので、それを LINE WORKS Developer Console のボット登録を行った画面で編集を行い、 Callback URL として設定します。

LINE WORKS ボットからのメッセージを受け付ける Lambda 関数の実装

これで、LINE WORKS ボットがメッセージを受け取ると、 Callback URL ( API Gateway ) を通じて、Lambdaが呼ばれるようになりました。 この受信メッセージ用のLambdaでは、主に次の処理を行います。

  1. 受信メッセージのチェック(改ざん確認)
  2. 予約投稿したい内容の取得
  3. CloudWatch Events へ登録
受信メッセージのチェック

最初に行うのは受信メッセージのチェックです。 受け取ったメッセージが正しく LINE WORKS ボットから送られたものであるか(改ざんがないか)を確認します。

確認方法を公式ドキュメントで見ると、X-WORKS-Signature のヘッダを使うとの記述があります。

これについては、 API Gateway から Lambda に送られてくるパラメータにどういう形で設定されているか一度確認しておきます。 Lambdaの先頭に、

    console.log("event: " + JSON.stringify(event));

とログを埋め込んでおきます。

この状態で、 LINE WORKS にログインして、予約投稿ボットにメッセージを送ると、

f:id:junichim:20190611224955p:plain

下記のように Lambda のログ(CloudWatch Logs)に event の内容が出力されているのがわかります。

    "body": "{\"type\":\"message\",\"source\":{\"accountId\":\"LINE WORKS のユーザーID\"},\"createdTime\":1559921558817,\"content\":{\"type\":\"text\",\"text\":\"てst2\"}}",

また、headers に

    "headers": {
        ・・・
        "x-works-botno": "nnnnnn",
        "x-works-signature": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    },

と追加ヘッダが存在しているのがわかります。

使うべき情報されわかれば、あとは公式ドキュメントの通りに処理を実装します。

const crypto = require("crypto");

function isValidMessage(body, signiture) {
    const hmac = crypto.createHmac("sha256", API_ID);
    hmac.update(body);
    const str = hmac.digest('base64');

    console.log("message body: " + body);
    console.log("message hmac: " + str);
    console.log("signiture   : " + signiture);

    return str === signiture;
}

これで、メッセージの改ざんチェックができるようになりました。

(参考) [Sy] HMAC-SHA-256 を計算するサンプル(JavaScript) | Syntax Error.

予約投稿したい内容の取得

event.body は文字列なので、parseして、JSONオブジェクトに変換しておきます。

    const body = JSON.parse(body_str);

body オブジェクトに type プロパティがあります。これの値で送られてきたメッセージの種類が判別できます。

最初にボットとの会話を開始すると type が join / joined になったメッセージもやってきます。 ここでは、 type が message のもの(一般のメッセージ)を対象にします。message の場合は、

  • body.source.accountID に送信者のID
  • body.content.text に送られたメッセージの内容

などにほしい情報があるので、これらを取得しておきます。詳しくは公式ドキュメントをご覧ください。

なお、今回は、予約投稿を実施する日時、送信先は固定で扱ってるのでここでは取得していません。

CloudWatch Events への登録

受信処理の最後は、指定された日時に送信用メッセージサーバ(Lambda関数)を呼び出すように、CloudWatch Events を構成します。

CloudWatch Events をAPI で生成するには、

  • putRule を呼び出して、ルールを作成
  • putTargets を呼び出して、ルールに対してターゲット(Lambda関数)を指定

という流れになります。

ルールの作成

ルールは、指定時間に呼び出したいので、cron式で指定します。 例えば、現在時刻の3分後に投稿するならば、

function getCronExpression() {
    // cron 式の生成
    // UTC
    const nw = new Date(Date.now() + 3 * 60 * 1000); // 3分後

    const SP = " ";
    const minute = nw.getUTCMinutes();
    const hour = nw.getUTCHours();
    const date = nw.getUTCDate();
    const month = nw.getUTCMonth() + 1;
    const year = nw.getUTCFullYear();
    return "cron(" + minute + SP + hour + SP + date + SP + month + SP + "?" + SP + year + ")";
}

のようにcron式を作ればOKです。なお、cron式の日時指定はUTCになります。

このcron式を使って、putRule呼び出し用のパラメータを構成します。

async function createCloudWatchRule() {
    let cwe = new AWS.CloudWatchEvents();
    let params = {
        Name: 'post_reservation', /* required */
        Description: 'LINE WORKS post reservation, from, to, at,',
        ScheduleExpression: getCronExpression(),
        State: "ENABLED"
    };

Name はルールの名前になります。ルール削除時にも使います。

また、同じルール名を指定して putRule を呼ぶと、エラーになるのではなく、既存のルールが更新されます。このため、複数の予約投稿を扱いたい場合はルール名が一意になるように工夫する必要があります(今回は未対応です)。

ScheduleExpression にcron式を指定します。

パラメータが設定できれば、あとはputRuleを呼び出すだけです。

    return new Promise((resolve, reject) => {
        cwe.putRule(params, function(err, data) {
            if (err) {
                console.error("createCloudWatchRule err: " + JSON.stringify(err), err.stack);
                reject(err);
            } else {
                console.log("createCloudWatchRule: " + JSON.stringify(data));
                resolve(params.Name); // ルール名を返す
            }
        });
    });
}
ターゲットの指定

ターゲットの指定も同様に行います。

まず、ターゲット指定用のパラメータを作成します。

Arn に呼び出したい Lambda 関数のARNを指定します。Id にこのターゲットを特定するIDを指定します。putRuleと同様に同じIDを指定して putTarget を呼ぶと、設定内容が更新されます。また、IDを変えれば複数の Lambda 関数呼び出しを登録することもできます(同じ Lambda関数でもOK)。

async function assignRuleToLambda(rule_name, id_from, id_to, message) {
    let cwe = new AWS.CloudWatchEvents();
    let params = {
        Rule: rule_name,
        Targets: [
          {
            Arn: LAMBDA_ARN_SEND,
            Id: '1', /* required */
            Input: "{ \"rule_name\": \"" + rule_name + "\", \"id_from\": \"" + id_from + "\", \"id_to\": \"" + id_to + "\", \"message\": \"" + message + "\" }",
          }
        ]
    };

また、今回はサンプル実装のため、投稿する内容、宛先などの情報を Lambda 関数に対するパラメータとして渡しています。 これは、 Input 要素に JSON 文字列として記述することで渡すことができます。 パラメータの受け渡しには、 Input, InputPath, InputTransformer の3種類が使えます。詳しくはAWSのドキュメントをご覧ください。

あと、後述するように投稿完了後 CloudWatch Events のルールを削除する必要があるので、このルール名もパラメータとして渡しておきます。

パラメータができれば、 putTargets を呼び出せばOKです。

    return new Promise((resolve, reject) => {
        cwe.putTargets(params, function(err, data) {
            if (err) {
                console.error("assignRuleToLambda error: " + JSON.stringify(err), err.stack);
                reject(err);
            } else {
                console.log("assignRuleToLambda: " + JSON.stringify(data));
                if (data.FailedEntryCount != 0) {
                    console.error("assignRuleToLambda has error: " + JSON.stringify(data));
                    reject(data);
                }
                resolve(data);
            }
          });    
    });      
}

putTarets の戻り値には FailedEntryCount があり、これが1以上になっているとどれかのターゲット登録(または更新)に失敗したことを意味します。 上記では、FailedEntryCount が0であることを確認しています。

(参考)

CloudWatch から呼び出され、 LINE WORKS ボットへ投稿を行う Lambda 関数の実装

次に、CloudWatchEvents から呼び出され、LINE WORKS ボットへ投稿を行う Lambda 関数を実装していきます。 この送信メッセージ用のLambdaでは、主に次の処理を行います。

  1. パラメータの取得
  2. サーバートークンの取得
  3. LINE WORKS ボットへ送信
  4. CloudWatch Event Rule の削除

パラメータの取得

CloudWatchEvents のターゲット登録の際に Input 要素で指定していたパラメータを取得します。

呼び出された Lambda 関数の event オブジェクトがそのままInputで指定した JSON になっています。

    // パラメータ取得
    const id_from = event.id_from;
    const id_to = event.id_to;
    const org_msg = event.message;
    const rule_name = event.rule_name;

サーバートークンの取得

LINE WORKS Developer の公式ドキュメントにのっとって処理します。

まず、JWTを取得します。

const jwt = require('jsonwebtoken');

function getJwt() {
    const iat = Math.floor(Date.now() / 1000); // msec-> sec
    const exp = iat + (60 * 30); // 30分後
    let token = jwt.sign({
        iss: SERVER_ID,
        iat: iat,
        exp: exp
    }, PRIVATE_KEY, {algorithm: 'RS256'});
    return token;
}

次に、サーバートークン取得用のAPIを呼び出します。

const request = require("request-promise");

async function getServerToken() {
    const jwtoken = getJwt();
    console.log("jwtoken: " + jwtoken);

    const headers = {
        "Content-type": "application/x-www-form-uelencoded; charset=UTF-8"
    };
    const options = {
        url : TOKEN_URL,
        method : "POST",
        headers : headers,
        form : {
            "grant_type": encodeURIComponent("urn:ietf:params:oauth:grant-type:jwt-bearer"),
            "assertion" : jwtoken
        },
        json : true
    };

    return request(options)
        .then((body) => {
            console.log("getServerToken:" + JSON.stringify(body));
            return body.access_token;
        });
}

問題がなければ、サーバートークンが取得できます。

(参考)

LINE WORKS ボットへ送信

サーバートークンさえ取得できれば、あとは LINE WORKS ボットAPIを呼び出すだけです。

ヘッダにServer API Consumer Key と取得したサーバートークンを指定します。

async function sendMessage(serverToken, dst_id, org_msg) {
    const headers = {
        "Content-type": "application/json; charset=UTF-8",
        "consumerKey": SERVER_CONSUMER_KEY,
        "Authorization": "Bearer " + serverToken
    };

投稿する内容、ヘッダ、ボットNoを指定したオブジェクトを作成し、

    const options = {
        url : SEND_URL,
        method : "POST",
        headers : headers,
        json : {
            "botNo": Number(BOT_NO),
            "accountId": dst_id,
            "content": {
                "type": "text",
                "text": org_msg
            }
        }
    };

request-promise モジュールを使って送信用のボットAPIを呼び出せばOKです。

    return request(options)
        .then((body) => {
            if (body.code != 200) {
                console.error("sendMessage error: " + JSON.stringify(body));
                throw new Error(body.errorMessage);
            }
            console.log("sendMessage success: " + JSON.stringify(body));
            return "finish";
        });

}

CloudWatch Events Rule の削除

送信が無事に終わったら、 CloudWatch Events のルールを削除しておきます。ルールの削除は

  1. ルールに含まれるターゲットをすべて削除
  2. ルールの削除

の順番で行う必要があります。実際には、ルールに含まれるすべてのターゲットを指定するためにターゲットのリストアップも必要になるため、それぞれ

  1. ルールに含まれるすべてのターゲットIDを取得: CloudWatchEvents#listTargetsByRule
  2. ルールに含まれるターゲットをすべて削除: CloudWatchEvents#removeTargets
  3. ルールの削除: CloudWatchEvents#deleteRule

のAPIを呼び出して実行すればOKです。ここにはコードは載せませんが、気になる方は GitHub リポジトリのほうを見てください。

実行

これで一通りの実装が終わりました。テストしてみます。

LINE WORKS にログインして、一人のユーザーから

f:id:junichim:20190612092638p:plain

『ブログ用にテスト』という内容で予約送信ボットに投稿を投げました。

するとおおよそ3分後、別のユーザーに対して

f:id:junichim:20190612093841p:plain

のように投稿が送られてきました。

投稿が行われる前に、AWS にログインして CLoudWatch Events を見ると、ルールが

f:id:junichim:20190612101345p:plain

のように設定されており、ターゲットも、

f:id:junichim:20190612101456p:plain

のように追加されていました。もちろん、投稿が終わると削除されました。

うまく動作しているようです。

今後

さて、ここまでで予約投稿を行うことはできたのですが、最初に書いたように実際に使えるようにするためには、ボットとの対話が欲しいところです。

今回の予約投稿だと、

  • 宛先
  • 送信日時
  • 送りたい内容

の3項目を入力する必要があります。これだけなら、自然言語処理とか入れずに自分で実装することもできるかな?と一瞬思ったのですが、やはりそれなりに大変そうなのでやめました。

そういうわけで、ボット用のフレームワークを探しているところです。 最近、AWS ばっかり使って Amazon にベンダーロックインされている身としては、Amazon Lex を使いたいところなのですが、Lex は日本語に対応していないとのこと。 なので、この先どう進めるか思案中です。

今のところ DialogFlow が有力かな?そのあたりは実装したらまた記事にまとめたいと思います。

あ、ソースコード一式を GitHub に置きました。

github.com

もしご興味があれば試してみてください。

補足

実装時の注意点を最後にいくつか補足しておきます。

Lambda の環境変数でのプライベートキーの指定方法について

今回実装した Lambda 関数では、サーバートークンを取得するための秘密鍵を環境変数で与えています。 秘密鍵を環境変数で与える際には、改行文字も正しく与える必要があるようです。

なので、下記記事などを参考に、

[RS256] JWTでRSA秘密鍵を環境変数で処理したい [Javascript] - Qiita

$ cat private.key | sed -e :xxx -e 'N; $! b xxx' -e 's/\n/\\n/g'

(xxxはラベル名なのでなんでもOKです)

として、1行化し、Lambda のコンソールから環境変数に設定します(コンソールで入力する際はダブルクォーテーションで囲む必要ありません)。

Lambda 側では

const PRIVATE_KEY = process.env.LINE_WORKS_PRIVATE_KEY.replace(/\\n/g, "\n");

のようにして、1行化した文字列を複数行に直しています。

(参考) sed のラベルコマンドなどについて詳しく解説してくれています。

sed ファイル内の改行を削除, 連続した空白を1つにする - Qiita

Lambda 関数のローカル開発について

こちらの記事にあるように Lambda の開発では index.js を呼び出す proxy.js などを作成し、そちらで dotenv を使って環境変数を定義すると、Lambda にアップロードするコードを変更することなく、ローカルでも

$ node proxy.js

のように実行できる、という形が作れます。

API Gateway からの呼び出しを模擬したければ proxy.js 内で定義している event に API Gateway から送られてくる event を張り付けてやればよいかと思います(eventの内容は一度 API Gateway 経由で Lambda を動かし、 CloudWatch Logs で確認すればよいかと思います)。

とはいえ、毎回、API Gateway からの呼び出しを模擬するのも面倒だったので、途中からはローカルの開発環境ではパッケージのインストールぐらいしか行ってなかったんですが、手元でさっと動かせるのは便利でした。

ご参考までに。