最強の「Microsoft Bot Framework」とElectronで実用チャットボット作ってみた

2017/03/30

Almir Bijedic

93

Articles in this issue reproduced from SitePoint
Copyright © 2017, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2017, KADOKAWA ASCII Research Laboratories, Inc. Japanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

チャットボット全盛時代。ElectronとMicrosoft Bot Frameworkを使って、スクラム開発のためのちょっとまじめなチャットボットを作ってみました。

チャットボットはますます存在感を増しています。FacebookはMessengerボットの開発フレームワークの提供を開始し、ビジネスオーナーがFacebookのメッセンジャーアプリ内だけで顧客に対応できるようにしました。ピザの注文、次回検診の予約、次の旅行のための最安値便の検索、なんでもできます。メッセンジャーアプリのコンタクトリストから、友人に連絡するように、知りたいことをボットに尋ねてください。

Facebookメッセージング部門副社長(VP)David Marcusは、11月に開かれたWebサミットでFacebookのチャットボットへの取り組みについて語りました。今後ユーザーとビジネスオーナーがメッセンジャーで顧客に対応できるだけでなく、普段Webページやアプリのフォームでの行動まで(食べ物の注文、購入する車の検討、そのほかなど)を可能にする、壮大な計画が明かされました。

この記事ではElectronMicrosoft Bot Framework(MBF)を使って、日次スクラムミーティング(後述)のためのSkypeボットを作ります。

どのような選択肢があるのか

技術的な面から、一番人気のあるフレームワークはMicrosoft Bot Frameworkで、基本的に現在の定番チャットアプリすべてに接続が可能です。

bot framework connectors: Kik, Skype, Twilio, Telegram, Microsoft Teams, Office 365 mail, Slack, Facebook Messenger, GroupMe

ただし、ほかの選択肢も登場しています。

これから作るボットについて

この記事ではElectronでチーム編成やメンバー追加ができる設定用GUIを作り、Microsoft Bot Framework(MBF)でボットを作成します。ボットが設定を読み込んで、全参加者に対し日次スクラムミーティングの3つの質問を配信します。全員が回答したら、ボットはミーティングの要旨をチームの参加者全員に配信します。

ラグビーボットですか?

Rugby ball

いいえ、「スクラム」とは言っても、ラグビーボットを作るわけではありません。スクラムミーティングと聞いてピンと来ない方のために、以下にその要点をまとめます。

スクラムとは、ソフトウェア開発チームなどアジャイル開発の過程で、場合によりますが特に3~6人までのチームで効果的とされている手法の1つで、決まったルールがあります。ルールと方法は、以下のようになります。非常に大雑把な説明なので、実際はどのチームも必要に合わせて少し変更しています。

  • タスクはどのように作られ、なにを具体化しなければならないか
  • 前回のタスク完了時間に基づいた、完成までに要した時間を測る指標
  • チーム構成員の役割分担
    • プロダクトオーナー:最終的な決定者。開発中の製品の顧客の意見を聞き、それに基づいて開発者が自由に取り組めるユーザーストーリー(タスクのおしゃれな呼び名)を作る
    • 開発チーム:技術者たち
    • スクラムマスター:後ろに控えて、チーム全員がルール通りに動いているかを確認する
  • チーム内の良好な、特に対面のコミニュケーション
  • チーム内で開くべきミーティング
    • ミーティングを開く頻度
    • ミーティングで議論するべき議題

こうしたミーティングの1つが、日次スクラムミーティングです。通常は毎朝一番に、参加者それぞれが前日の開発の進捗状況を残りのメンバーに伝えて情報共有します。加えて参加者は、その日の予定を伝えたあと、障害となる事柄、特にタスク進捗の妨げになりそうな課題について話します。

通常は参加者が会って、毎日スクラムミーティングをしますが、時差や地域差のあるリモートチームではここが問題になります。そこで、これから作るボットが出番となるのです。

設定用のGUI

必要なものは以下のようになります。

ボットのコードと設定画面のコードはどちらもこちらのリポジトリに用意されています。

ボイラープレート(定型文)

Electronをよく知らない場合は、基本および短期間で人気となった理由を説明しているこちらの記事を読むのが良いです(少なくとも導入部)。新たに登場する数多くのデスクトップアプリが使っています(Slack、Visual Studio Codeなど)。

ボイラープレート(定型文)コードのセットアップにはYeoman generatorを使います。

プロジェクトを保存したいフォルダーから、以下のコマンドを実行します。

npm install -g yo generator-electron

Electronのパッケージがグローバルにインストールされます。以降は、次の手順で説明するように、ジェネレーターをどこからでも呼び出せます。

yo electron

これでElectronアプリで「Hello World」を実行するために必要なファイルがすべて揃います。このときnpm installが自動で実行されるので、Yeomanの作業が終わればすぐに以下のコマンドを実行できます。

npm start

新しいアプリのウィンドウが表示されます。

The default screen of a new Electron Boilerplate app

エントリーポイント

index.jsはこのアプリのエントリーポイントです。開いて中がどうなっているか確認しましょう。

function createMainWindow() {
  const win = new electron.BrowserWindow({
    width: 600,
    height: 400
  });

  win.loadURL(`file://${__dirname}/index.html`);
  win.on('closed', onClosed);

  return win;
}

createMainWindow()メソッドは、BrowserWindowクラスのコンストラクタを呼んでメインウィンドウを作成します(メソッド名からして当たり前ですが)。ウィンドウ幅(width)、高さ(height)、背景色(background color)、そのほかの多数のオプションを指定できます。

この関数で覚えておきたいのは、win.loadURLメソッドです。なぜ重要なのでしょうか。実はこのアプリのコンテンツは単なるHTMLファイルなのです。デスクトップアプリを作るのに、魔術も新しい関数もフレームワークも学習しなくてよいのです。ただWeb開発技術さえあればよいので、Webの開発者がデスクトップアプリの開発者になれるのです。

const app = electron.app;

app.on("window-all-closed", () => {
  // ...
});

app.on('activate', () => {
  // ...
});

app.on('ready', () => {
  // ...
});

Electronが提供するイベントのコールバックのリストはこちらです。

  • ready:すでにjQueryになじみがあるなら、readyイベントはjQuery(document).ready()のようなものだと考えてかまわない
  • activate:アプリのウィンドウにフォーカスが当てられた際に毎回実行される
  • windows-all-closed:アプリのウィンドウがすべて閉じられたときにかかるトリガーで、あとの処理ができる。ただし、呼ばれないケースもある(コードからapp.quit()を実行した場合、ユーザーがCmd + Qキーを押した場合)ので注意が必要

アプリのロジック

エントリーポイントのindex.jsファイルには、特定のコードを起動・終了するためのコードがあるのみで、主にグローバルセットアップで使用します。index.jsファイルにアプリのロジックは書きません。すでに述べたように、アプリ自体はただのHTMLファイルなのです。index.htmlに設定用GUIのための要素を追加します。

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Electron boilerplate</title>
    <link rel="stylesheet" href="index.css">
  </head>
  <body>
    <div class="container">
      <section class="main">
        <h2>Teams</h2>
        <div>
          <select id="teams">
            <option>Select a team...</option>
          </select>
          <input type="text" id="newTeamName" placeholder="New team name..."/>
          <button id="addTeam" disabled type="button">+</button>
        </div>

        <div id="members"></div>
        <button id="addMember" type="button">+</button>

        <p id="message"></p>
        <button id="save" type="button">Save</button>

      </section>
      <footer></footer>
    </div>
    <script src="app.js"></script>
  </body>
</html>

上のコードを、現在のHTMLと置き換えてください。bodyの最後にはスクリプトファイルapp.jsへの参照を加えていますが、app.jsがアプリのロジックを書く場所です。Electronアプリのウィンドウは実は埋め込まれたブラウザーウィンドウにすぎないため、開発中は普段と同じショートカットキーを使ってコードの実行(F5あるいはCtrl + R)や、Chrome風の開発者ツールを開けます(F12キー)。

プロジェクトのルートにapp.jsという名前で新規ファイルを追加して、このコードを貼り付けてください。特に目新しいことのない、古き良きJavaScriptのコードです。

保持しておくデータの取り扱い方法は、JSONファイルがあれば十分です。もし、アプリをさらに発展させたい場合は、データベースを使う方法に切り替えます。

新規チームを追加するボタンがあり、チームには参加者が追加できるようになっています。各参加者はSkype名で表示されます。あとでボットを作成しますが、ボットのエミュレーターに組み込まれているチャットクライアントを使ってテストができます。ユーザー名はuserです。

また、ドロップダウンメニューからチーム名を選択して呼び出せます。各チームの一番下にはスクラムミーティングの時間を入力できます。この値が、午前0時からミーティングの時間までの秒数を示すタイムスタンプとして保存されます。

作成した設定ツールを立ち上げて、新規チームと「user」という名前のユーザーを追加します。

The team management screen, with options to select or add a team

Typing in a new team name

The 'New team added!' success message

ドロップダウンメニューから作成したチームを選択してユーザーを追加できるようになりました。

重要:エミュレーターのユーザー名はあらかじめ決まっていて変更できないので、追加するユーザーの名前は「user」でなければなりません。テスト時にボットが認識できるように必ず「user」としてください。

時間を00:00(あるいは好きな時間)として、Saveをクリックします。

teams.jsonファイルは次のようになっています。

{
  "alpha": {
    "members": {
      "user": {},
      "almir bijedic": {}
    },
    "time": 0
  }
}

このファイルはあとでボットが使用します。

ボット

bot

Microsoft Bot Framework

MBF(Microsoft Bot Framework)のSDKは、C#版とNode.js版の2つがあります。記事ではNode.js版を使います。ボットは手動でREST APIから呼び出すか、オープンソースのSDKを使って呼び出します。記事ではより手早いSDKを使います。一方、カスタム関数でAPIからボットを呼ぶ方法は、既存アプリにボットを追加したい場合や、なんらかの理由でNode.jsやC#を使えない場合に便利です。

ローカル環境でボットの試験をするには、2つの方法があります。

  1. コマンドラインでボットとやりとりできるConsoleConnectorを使用する
  2. 上とは別の方法restifyChatConnectorクラスを使用してローカルサーバーを立ち上げ、PCローカルで架空のユーザーとして動作するMicrosoft提供のボットエミュレーターを実行する

ここでは2番目の、いわば「より現実的な」方法を使います。

Route

チャットボット作りでメインとなるクラスはUniversalBotです。もう1つ知っておきたいのがUniversalCallBotというクラスでSkypeコールをかけられるのですが、本記事では触れません。そもそも、私たちは電話よりも文字によるメッセージを好む傾向があり、わざわざSkypeコールをかけずにやりとりできることがこのチャットボットのメリットですから。

ボットがユーザーから来たメッセージに対してどのように応答するかの決定はrouteによります。対話形式のWebアプリに似ていて、たとえば、次のようなものです。

// bot is an instance of UniversalBot
bot.dialog("/", function (session) {
  session.send("Hello World");
});

このbotというのはUniversalBotクラスのインスタンスです。

これで、ユーザーがボットへなにかメッセージを送ると毎回「Hello World」が返されます。

bot.dialog()は2つの引数を取ります。routeとrouteがアクティブだった場合に実行する関数です。ウォーターフォールダイアログ(ウォーターフォールについては次項で説明します)の場合、2番目の引数は関数が入った配列でもよく、関数が順番に実行されてユーザーとやりとりができます。

■初期設定
それでは始めましょう。Electronプロジェクトに戻り、botという名前でフォルダーを新規作成します。フォルダー内でnpm initを実行したら基本情報を記入しますが、エントリーポイントとしてapp.js、開始スクリプトとしてnode app.jsを指定するだけです。完了したらbotフォルダー直下にapp.jsという新規ファイルを作成します。

ここでボットに依存オブジェクトをインストールします。

npm install --save botbuilder restify fs-extra

次にbotフォルダー内に作ったapp.jsファイルを開き、必要なライブラリーを読み込みます。

// app.js

var restify = require("restify"),
  builder = require("botbuilder"),
  fse = require("fs-extra");

ここで、ある時点の接続を監視するリスナーとしてrestifyサーバーを設ける必要があります。

// app.js

// Setup Restify Server
var server = restify.createServer();
server.listen(process.env.port || process.env.PORT || 3978, function () {
  console.log("%s listening to %s", server.name, server.url);
});

restifyサーバーをMBFのボットRESTサービスに接続します。

// Create chat bot
var connector = new builder.ChatConnector({
  appId: process.env.MICROSOFT_APP_ID,
  appPassword: process.env.MICROSOFT_APP_PASSWORD
});
var bot = new builder.UniversalBot(connector);
server.post("/api/messages", connector.listen());

ログイン認証には、環境変数MICROSOFT_APP_IDMICROSOFT_APP_PASSWORDがNodeで使えます。これはMicrosoft Bot Directoryに対する認証に使用されます。

:ChatConnectorの代わりとしてConsoleConnectorがあり、ConsoleConnectorはアプリ実行時にコンソールからの入力を求める方式です。この方法であれば、あとでインストールするエミュレーターは不要です。

もちろん、routeのルート(root)には、「Hello World」を出力するだけの簡潔なダイアログを加えます。

bot.dialog("/", function(session) {
  session.send("Hello World!");
});

なぜChatConnectorを使うのか、なぜrestifyサーバーが必要なのかなど、少しややこしいため、この仕組みを俯瞰したのが下の図です。

bot framework architecture

ユーザーは作成したボットをSkypeコンタクトに追加します。

  1. ユーザーがSkypeクライアントからボットにメッセージを送る。メッセージはSkypeのサーバーへ送られ、すでに登録してあるボットへ渡される
  2. 登録の際ボットにhttpエンドポイントを指定したが、ボットのコードが実行されるサーバーを指す。Skypeサーバーは、メッセージを詳細情報付きでrestifyサーバーへ転送する
  3. ChatConnectorがrestifyサーバーからのリクエストを受け取って意図した通りに処理する
  4. Bot Framework SDKが内容に応じた応答を生成し、サーバーに返信する。登録の際に指定したアプリID(APP ID)とパスワードは、ボットがSkypeサーバーにアクセスするのに必要となる。ボットは上の「2」のメッセージと一緒に、REST APIの場所を受け取る
  5. Skypeサーバーが応答を認識してユーザーにメッセージを転送する

作成した単純なボットをテストするため、エミュレーターをダウンロードしてインストールします。エミュレーターは上の図の左側にあたる、Skypeクライアント(ユーザー)側とSkype REST APIサーバー側の両方の役を演じます。

エミュレータのページからダウンロードして、インストールしたら実行します。

Microsoft Bot Framework Emualator

エミュレーターに、ボットのコードが実行されるエンドポイントを指定します。

botフォルダーに戻ってnpm startを実行します。以下のように表示されます。

restify listening to http://[::]:3978

このポートを変更するには、Nodeの環境変数PORTの値を変えるか、ファイル先頭のフォールバックの値3978を変更します。

今回はローカルホストのエンドポイント、3978番ポートです。これをエミュレーターに指定します。加えて/api/messagesルートを監視することを思い出してください。

emulator connect config

テストはローカルで実行するのでMicrosoftアプリIDとパスワードは空欄のままでかまいません。CONNECTをクリックしてください。

これでボットを試せます。上で設定したとおり、何度やってもHello Worldメッセージが返ることでしょう。

emulator hello world

ボットはもっと賢くないといけません。次項では以下のようなrouteを実装します。

  • /:ルート(root)ダイアログは、登録済みユーザーがスクラムミーティング以外の時に、メッセージをボットに送信した場合のみに使う。ルートダイアログを設ける唯一の理由は、登録やミーティング以外のときでもボットはちゃんと聞いていて、なにか反応したことをユーザーに示すため
  • /firstRun:あとでユーザーにメッセージ送信するためには、どうにかしてユーザーを登録しアドレスを保存しなければならない
  • /dailyScrumDialog:全日次のスタンドアップ・ミーティングの時間をチェックするためにsetInterval()で実行されるタイマーを使う。あるチームのミーティング時間が来たら、ボットに登録された全チームメンバーが検索される。ここの「登録」とは、設定GUIでチームに登録、自分のSkypeコンタクトリストにボットを追加済み、最低1つはボットにメッセージを送っている、以上3つの条件を満たした状態のこと
  • /report:チームの全参加者にミーティングのレポートを配信するだけのもっとも簡潔なダイアログ。これはsetInterval()で実行される関数からトリガーがかかり、全参加者が3つの質問に回答し終えたかをチェックする。もし完了していれば参加者に全員分の回答を配信する

ウォーターフォール

ウォーターフォール(訳注:Waterfall、滝)はもっとも基本的なボットのダイアログ形式で、その名の通りに動作します。つまり順に進んでいき、戻ることはありません。ボットのdialog関数の第2引数として関数の配列を渡します。各関数は順番に実行され、前の関数の実行結果があとの関数で参照されます。

builder.Prompts.text(session, "Message to send")は、ユーザーの入力を要求する主な方法です。ユーザーが回答したら、配列内の次の関数が実行されます。今回は2つの引数があります。sessionオブジェクトと、ユーザーメッセージが含まれた結果のオブジェクトです。

bot.dialog("/", [
  function (session) {
    builder.Prompts.text(session, "Hey there, how are you doing?");
  },
  function (session, results) {
    console.log(results.response); // This will print out whatever the user sent as a message
    session.send("Great! Thank you for letting me know.")
  }
]);

前回作ったルートダイアログを今回のコードと入れ替えて、試します。

なお次回以降も参照するユーザーデータはここで保存できます。

bot.dialog("/", [
  function (session) {
    if (session.userData.howIsHe) {
      session.send(session.userData.howIsHe);
    } else {
      builder.Prompts.text(session, "Hey there, how are you doing?");
    }
  },
  function (session, results) {
    session.userData.howIsHe = results.response;
    session.send("Great! Thank you for letting me know.")
  }
]);

実行するとユーザーの回答を保存し、次回以降のメッセージに対して応答を返します。

emulator waterfall

ダイアログスタック

もう分かったかもしれませんが、ボットは一連のダイアログを通じてチャットを構成します。ユーザーとの会話が始まるとボットは、初期表示するダイアログをスタックの一番上にプッシュ(追加)します。そのあと、以下の関数によってユーザーダイアログは分岐したり終了したりするのです。

■session.beginDialog(route, args, next)
この関数は現在のダイアログを停止し、特定ルートのダイアログをスタックの一番上に追加します。ダイアログが完了すると、beginDialog()関数を呼んだ元のダイアログに戻ります。

■session.endDialog()
endDialog()を実行すると、現在のダイアログがスタックからポップ(取り出し)され、スタック上でその次にあるダイアログに遷移します。

■session.endDialogWithResult(args)
endDialog()と同じですが、ダイアログ(スタック上で次にあるダイアログ)の呼び出し時に、変数を渡せる点が異なります。

■session.replaceDialog(route, args, next)
新しいダイアログが完了したあとで前のダイアログに戻りたくない場合は、beginDialog()の代わりにreplaceDialog()を使います。

■session.cancelDialog(dialogId, replaceWithId, replaceWithArgs)
この関数でダイアログをキャンセルすると、引数で渡したダイアログIDに到達するまで順にスタックからダイアログが取り除かれていきます。このIDのダイアログもキャンセルされて、権限は関数が呼ばれた元へ戻ります。関数の呼び出し元では変数results.resumedによりキャンセル結果を参照できます。

あるいは、関数の呼び出し元に戻る代わりに引数で指定したIDのダイアログに入れ替えることもできます。

■session.endConversation()
ダイアログをキャンセルするのに便利な方法です。基本的にはsession.cancelDialog(0)(この0はスタックのいちばん下のダイアログIDなので、全ダイアログがキャンセルされる)のようなものです。これはユーザーのセッションデータを全部消去したい場合にも便利です。

開始処理のためのミドルウェア

ボットは、ユーザーが会話を開始してくれるまではSkypeユーザーと会話できません(MBFは多数のチャットクライアントに対応していますが、この点ではほかのどのチャットプラットホームでも同じです)。理由は、主にスパムを防ぐためなので納得できるはずです。

対話を始めるにはユーザーアドレス(ユーザーID、会話ID、そのほか情報を持つオブジェクト)が必要なので、今後のためにユーザーアドレスを保存する、なんらかの初期処理コードが必要です。

MBFでは、対話の開始時にユーザーを導きたいルートを指定できるミドルウェアが用意されています。

var version = 1.0;
bot.use(builder.Middleware.firstRun({ version: version, dialogId: "*:/firstRun" }));

初めて登録するユーザーを「firstRun」ルートに導けるので、以下のように定義します。

bot.dialog("/firstRun", [
  function (session, args) {
    if (session.userData.user && session.userData.team) {
      session.userData["BotBuilder.Data.FirstRunVersion"] = version;
      session.replaceDialog("/dailyScrum");
    } else {
      builder.Prompts.text(session, "Hello... What's your team name?");
    }
  },
  function (session, results) {
    // We'll save the users name and send them an initial greeting. All
    // future messages from the user will be routed to the root dialog.
    var teams = readTeamsFromFile();
    var providedTeamName = results.response.toLowerCase();
    var user = session.message.user.name.toLowerCase();
    if (teams[providedTeamName] && Object.keys(teams[providedTeamName].members).indexOf(user) > -1) {
      teams[providedTeamName].members[user].address = session.message.address;
      writeTeamsToFile(teams);
      session.userData.user = user;
      session.userData.team = providedTeamName;
      session.send("Hi %s, you are now registered for the %s team daily scrum. We will contact you at the time of the meeting, which is at %s", user, providedTeamName, timeToString(teams[providedTeamName].time));
    } else {
      session.send("Wrong team! Try again :D (%s)", user);
      session.replaceDialog("/firstRun");
    }
  }
]);

function readTeamsFromFile() {
  return fse.readJsonSync("./data/teams.json");
}

function writeTeamsToFile(teams) {
  fse.outputJsonSync("./data/teams.json", teams);
}

function timeToString(time) {
  return pad(parseInt(time / 60 / 60 % 24)) + ":" + pad(parseInt(time / 60) % 60)
}

function pad(num) {
  var s = "0" + num;
  return s.substr(s.length - 2);
}

ここでは2つ目の引数の配列に、2つの関数をセットし、順に呼ばれるようにしました。ユーザーが1つ目に対して回答したら、2つ目が呼ばれます。この場合、builder.Prompts.text(session, message)がユーザーに名前の入力をうながし、次の関数で入力されたチーム名をJSONファイルから検索します。チーム名が見つかったらユーザー名をJSONファイルに追加し「登録が完了したこと」「スクラムミーティング時間を今後通知すること」を知らせるメッセージを返します。

/firstRunダイアログ以外にもヘルパー関数があります。

readTeamsFromFile()は、チームのデータを収めたJSONファイルからJSONオブジェクトを返します。

writeTeamsTofile()は、オブジェクトを引数とし(例の場合はチームデータのJSONファイル)、ファイルに書き込みます。

timeToStringは、UNIXタイムスタンプを引数とし、文字列型にパースした時間を返します。

padは、文字列にゼロを加えるのに使用します(例:1時間と3分なら、1:30でなく01:03でなければなりません)。

前に示した2つの部分コードを、以下のコードと一緒にbot/app.jsファイルに追加し、npmからfs-extraライブラリーを読み込んだら、さっそく試します。

var restify = require("restify"),
  builder = require("botbuilder"),
  fse = require("fs-extra");

エミュレーターからメッセージを送る前に、いったんエミュレーターを終了して再度起動してください(エミュレーターのDelete User Data関数にはバグがあります)。

emulator first run

data/teams.jsonを開くと、エミュレーターの仮想のユーザーアドレスがオブジェクトとして保存されています。

{
  "alpha": {
    "members": {
      "user": {
        "address": {
          "id": "3hk7agejfgehaaf26",
          "channelId": "emulator",
          "user": {
            "id": "default-user",
            "name": "User"
          },
          "conversation": {
            "id": "5kaf6861ll4a7je6"
          },
          "bot": {
            "id": "default-bot"
          },
          "serviceUrl": "http://localhost:54554",
          "useAuth": false
        }
      }
    },
    "time": 0
  }
}

この初期ダイアログでもっと有意義なことをしてみます。ユーザーが/firstRunダイアログを終えたときに、なにか処理されたことをユーザーに知らせる表示を出力します。

bot.dialog("/", function(session) {
  // this is a hack in order to avoid this issue
  // https://github.com/Microsoft/BotBuilder/issues/1837
  if (!session.userData.team || !session.userData.user) {
    session.replaceDialog("/firstRun");
  } else {
    session.send("Hello there, it's not yet scrum time. I'll get back to you later.");
  }
});

ミドルウェア

最初に実行するミドルウェアはよくある普通のミドルウェアで、フレームワークに初期実装されているものです。またミドルウェアのカスタム関数も作れます。Skypeユーザーとチャット中に会話IDの変更もできるので、なにかメッセージを受信するたびにユーザーアドレス(会話IDも含まれる)を更新します。アドレスはメッセージごとに毎回渡されるのでapp.jsに加えます。

bot.use({
  botbuilder: function (session, next) {
    if (session.userData.team && session.userData.user) {
      var teams = readTeamsFromFile();
      teams[session.userData.team].members[session.userData.user].address = session.message.address;
      writeTeamsToFile(teams);
    }
    next();
  }
});

UniversalBotクラスのuse関数を使ってミドルウェアを追加します。botbuilderキーを持つオブジェクトが入っており、値は2つの引数sessionnextを持つ関数です。

ユーザーがすでに登録されているかどうかを、sessionのuserDataオブジェクトに含まれる変数から判断します。もし登録済みならJSONファイル内のアドレスデータを新しい値で上書きします。

タイマー

次に、チームにスクラムミーティング時間が来たかどうかを秒間隔でチェックする関数を加えます。ミーティング時間になったときに参加者のアドレスがすでにあれば(/firstRunで登録済みの場合)「/dailyScrum」ルートで対話を開始します。参加者のアドレスがなければ残念ながらそのユーザーは飛ばすしかなく、会議後に通知するのみです。

setInterval(function() {
  var teams = readTeamsFromFile();
  Object.keys(teams).forEach(function(team) {
    if (shouldStartScrum(team)) {
      teamsTmp[team] = { members: {} };
      Object.keys(teams[team].members).forEach(function(member) {
        if (teams[team].members[member].address) {
          bot.beginDialog(teams[team].members[member].address, "/dailyScrum", {team, member});
        }
      });
    }
  });
}, 3 * 1000);

function shouldStartScrum(team) {
  var teams = readTeamsFromFile();
  if (teams[team].time < 24 * 60 * 60 && getTimeInSeconds() > teams[team].time) {
    var nextTime = Math.round(new Date().getTime()/1000) - getTimeInSeconds() + 24 * 60 * 60 + teams[team].time;
    teams[team].time = nextTime;
    writeTeamsToFile(teams);
    return true;
  } else if (Math.round(new Date().getTime()/1000) > teams[team].time) {
    var nextTime = 24 * 60 * 60 + teams[team].time;
    teams[team].time = nextTime;
    writeTeamsToFile(teams);
    return true;
  }

  return false;
}

function getTimeInSeconds() {
  var d = new Date();
  return d.getHours() * 60 * 60 + d.getMinutes() * 60;
}

ファイルの先頭にグローバル変数teamsTmpを追加して、あとでレポート生成できるように参加者の回答を保存します。

var teamsTmp = {};

shouldStartScrum関数は、JSONファイル(ストレージおよびボットとElectronコンフィギュレータとの情報受け渡しを担う)にタイムスタンプがあるかをチェックします。本番のアプリではおすすめしません。あくまで記事の都合で、単純なスケジューラーを作ってBot Frameworkの機能を説明するためにしていることです。

日次スクラムのダイアログ

連続で3つの質問をして、レポート作成のために回答を変数に保存するウォーターフォール・ダイアログを作ります。これまでに説明したことを動員すれば比較的簡単です。このダイアログは前回作ったタイマーで開始されます。

/* Add a dailyScrum dialog, which is called when it's a time for a daily scrum meeting, prompting the user in a waterfall fashion dialog */
bot.dialog("/dailyScrum", [
  // 1st question of the daily
  function (session) {
    builder.Prompts.text(session, "What did you do yesterday?");
  },

  /* After the users answer the 1st question, the waterfall dialog progresses to the next function, with the 2nd question, but checking that the input for the previous question was not an empty string. If yes return the user to the first question by calling replaceDialog */
  function(session, results) {
    if (results.response.length > 0) {
      teamsTmp[session.userData.team].members[session.userData.user] = { q1: results.response };
      builder.Prompts.text(session, "What will you do today?");
    } else {
      session.send("It can't be that you did nothing %s! Let's try this again.", session.userData.user);
      session.replaceDialog("/dailyScrum");
    }
  },

  // 3rd question
  function(session, results) {
    teamsTmp[session.userData.team].members[session.userData.user].q2 = results.response ;
    builder.Prompts.text(session, "Are there any impediments in your way?");
  },

  /* Finalize and schedule a report for the user. After the user has answered the third and last daily scrum question, set the isDone variable for that user to true */
  function(session, results) {
    teamsTmp[session.userData.team].members[session.userData.user].q3 = results.response;
    teamsTmp[session.userData.team].members[session.userData.user].isDone = true;
    session.send("Got it! Thank you. When all the members finished answering you will receive a summary.");

    /* If the user is the first to finish for the team, create a checker function for the whole team, which
    will periodically check whether everyone from the team finished, if yes, send all the users in the team
    a report */
    if (!teamsTmp[session.userData.team].checker) {
      teamsTmp[session.userData.team].checker = setInterval(function() {
        if (isEverybodyDone(session.userData.team)) {
          teamsTmp[session.userData.team].isDone = true;
          clearInterval(teamsTmp[session.userData.team].checker);
          var teams = fse.readJsonSync("./data/teams.json");
          Object.keys(teamsTmp[session.userData.team].members).forEach(function(member) {
            bot.beginDialog(teams[session.userData.team].members[member].address, "/report", { report: createReport(session.userData.team) });
          });

          session.endDialog();
        }
      }, 1000);
    }

    session.endDialog();

  }
]);

function isEverybodyDone(team) {
  var everybodyDone = true;

  Object.keys(teamsTmp[team].members).forEach(function (x) {
    if (!teamsTmp[team].members[x].isDone) {
      everybodyDone = false;
    }
  });

  return everybodyDone;
}

function createReport(team) {
  // change to members
  var report = "_"+ team + "_<br />";
  report += "___________<br />";

  Object.keys(teamsTmp[team].members).forEach(function(member) {
    report += "**User:** " + member + "<br />";
    report += "**What did " + member + " do yesterday:** " + teamsTmp[team].members[member].q1 + "<br />";
    report += "**What will " + member + " do today:** " + teamsTmp[team].members[member].q2 + "<br />";
    report += "**Impediments for " + member + ":** " + teamsTmp[team].members[member].q3 + "<br />";
    report += "___________<br />";
  });

  return report;
}

メッセージのフォーマットにはマークダウンを使います。

すべての一番前、具体的には、bot.use(builder.Middleware.firstRun …の前に加えてください。

日次スクラムのダイアログの最後にはsetInterval()という別の関数を加えていることに注目してください。チームの最初のメンバーが回答したらチームの残りのメンバーが回答を終えたかチェックします。全員が回答したら、各参加者に対して新しいダイアログを発信し、生成したレポートを送ります(最後のダイアログパスとしてこの処理を書きます)。

bot.dialog("/report", function(session, args) {
  session.send(args.report);
  session.endDialog();
});

このレポートは、beginDialog関数に引数として渡して、呼ばれたダイアログのargsパラメーターから再び読み出すという仕組みです。

デモ

いよいよ試してみるときです。確実にユーザーデータがリセットされ最新コードが反映されるようにするため、エミュレーターとボットのスクリプトをいったん終了して再度立ち上げることをおすすめします。

また、JSONファイルのスクラムミーティング時間を変更し、前回設定した時間まで待たずにミーティング時間が来るように書き換えてください。

ボットになにか話しかけると、チーム名を尋ねてきます。

demo #1

エミュレーターないし類似のテストを開始した時点でスクラムミーティングの時間が「過ぎて」いると、このような応答になるので、エミュレーターがすぐに質問をしてこない場合は時間を0にセット(JSONファイルから直接書き換えるかElectronの設定から)して、ボットが今日のミーティングを開始するようにします。

時間を変更するとすぐに、スクラムミーティングの3ステップのダイアログが順番に出てくるはずです。

demo #2

複数人のユーザーで実行するには、Microsoft Bot Directoryの要求事項としてボットをSSL接続のサーバーにデプロイする必要があります。

さらに進化させる

まだMBFでできることのほんの一部しか紹介できていません。ボットを次のレベルに進化させるために、知っておく価値がある事柄を追加して説明します。

LUIS

Microsoft Bot Frameworkではまだまだ多くのことができます。おもしろいものの1つはLUIS(Language Understanding Intelligent Service)で、CortanaとBingのデータを使ってユーザーの言いたいことをAIがより深く理解する試みです。

インテントダイアログ

もっと単純な例はインテントダイアログで、通常のダイアログに似ていますが、最初の引数にはrouteの代わりにregex(訳注:regular expression、正規表現)を取ります。regexの内容からユーザーのインテント(訳注:意図)を検索し、それに応じて特定の処理を実行します。次のようなことです。

// example from https://docs.botframework.com/en-us/node/builder/chat/IntentDialog/
var intents = new builder.IntentDialog();
bot.dialog("/", intents);

intents.matches(/^echo/i, [
  function (session) {
    builder.Prompts.text(session, "What would you like me to say?");
  },
  function (session, results) {
    session.send("Ok... %s", results.response);
  }
]);

私が非常に良いと思った、マイクロソフト提供のサンプル集はこちらです。

最後に

ここまでで一通りのことに触れました。Electronの基本、スクラム、Bot Frameworkのダイアログスタック、ウォーターフォール型ダイアログ、メッセージ転送ミドルウェア、相手からのリクエストを待たずに不特定ユーザーと対話を開始する方法、などです。

※本記事はVildan SofticCamilo Reyesが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。

(原文:Make a Skype Bot with Electron & the Microsoft Bot Framework

[翻訳:西尾健史/編集:Livit

Copyright © 2017, Almir Bijedic All Rights Reserved.

Almir Bijedic

Almir Bijedic

Webに関することならなんでもこいのオールラウンダーです。Ubuntu Webサーバーから.NET WWFのサービスの設定、フロントエンドまであらゆるものに挑戦しました。最近ではJavaScriptに注目。Webの仕事をしていないときは大好きなIoTの世界に浸っているか、料理や映画鑑賞、ときにはビスケットを食べたりもしています。

Loading...