Azure Bot ServiceでSlackやTeamsなどの別チャネル間でのメッセージを仲介するBotを作る

やりたいことタイトルで伝わるか微妙ですが、要は以下の図のようにAzure Bot Serviceで仲介して複数のチャネルの人を繋げるやり方が無いかと調べ始めました。

f:id:yomon8:20180920131729p:plain

そこで見つけた、このあたりのIssueを読むと、できそうな気がしたので実際にやってみました。

Make bot send a message to a certain user in response to another user action · Issue #1957 · Microsoft/BotBuilder · GitHub

基本

Azure Bot Service基礎的なことは、こちらの記事には書くまでもなく、この自習書が丁寧にまとめられているので、とても役立つと思います。自習書はC#なのでNode.jsで開発する人は適宜読み替えが必要ですが。

◆【Microsoft Azure 自習書シリーズ】Cognitive Services と Bot Service で作る業務アプリケーション

http://download.microsoft.com/download/9/7/8/978ED1D1-A670-47A7-B478-5C0FEC542204/Hands-on-12-CognitiveServices_and_BotService_for_InternalApp.pdf

イメージ

処理フロー

ざっくりの処理フローはこのようなものを想定しています。

Bot Frameworkでは、Addressという属性があれば、特定のユーザにメッセージが送信できます。

実際には一人のユーザでも複数のチャネル(Address)を使っていることを想定して、BotとしてユーザのAddressをユニークに識別するHandleという属性持つ形にしてみました。

初回アクセス時にHandleを登録する仕様です。

f:id:yomon8:20180920212311p:plain

テーブル

HandleからAddress引く場合と、アドレスからハンドルネーム引く場合が考えられるので、以下のようなChatUserテーブルを作成してみます。

f:id:yomon8:20180920185448p:plain

以下を参考にしています。

パーティション内のセカンダリ インデックス パターン

https://docs.microsoft.com/ja-jp/azure/cosmos-db/table-storage-design-guide#intra-partition-secondary-index-pattern

環境構築

開発環境準備

大前提としては私の開発環境はUbuntuです。Windowsでないと問題出る部分はとりあえずはありませんでした。

VSCodeでNode.jsを開発する環境を整えるところは省略します。

Bot Framework Emulator

こちらからエミュレータを取得します。自習書にも記載があります。

GitHub - Microsoft/BotFramework-Emulator: Bot Framework Emulator

エミュレータがあればこのような感じでローカルPCでBotのテストができます。画面では現時点でPreview版のバージョン4使っています。

Azure Storage Explorer

Azure Storage Tableのデータ操作に便利なので、ここからダウンロードしておきます。

Azure Storage Explorer – クラウド ストレージ管理 | Microsoft Azure

Azure CLI

Azure CLIは以下のバージョンを利用しています。

$ az --version
azure-cli (2.0.44)

# 〜 省略 〜

Extensions:
botservice (0.0.3)

# 〜 省略 〜

現時点で最新版のAzure CLIはbotserviceの部分がExtension扱いではなく、本体のCLIに組み込まれていますが、うまく動かなかったので、こちらの手順でダウングレードして上記のバージョン利用しています。

aptで任意のバージョンを指定してazure-cliをインストールする方法 - YOMON8.NET

Azure Bot Serviceのデプロイ

サービスのデプロイしていきます。

デプロイ

初期Azure Bot Service作成

Azure Bot Serviceを作成します。CLIでもできるはずなのですが、どうも設定抜けてたり思った通りにいかなかったので、Portalからやりました。以下の設定の通りです。

f:id:yomon8:20180920175647p:plain

テーブル作成

上述のテーブル作成します。

$ az storage table create --account-name brokerbotstorage --name ChatUser

ソースコード

デフォルトコードのダウンロード

先程作成したAzure Bot Serviceのデフォルトのコードをダウンロードしてきます。

$ az bot download -g broker-bot-rg -n broker-bot

この時点ではソースコードは以下の通り。messagesの下にvscode用の設定も入っているので、ここを修正していきます。

$ cd broker-bot/bot-src/
$ tree -a
.
├── .funcpack
│   ├── index.js
│   └── original.index.js
├── .gitignore
├── PostDeployScripts
│   ├── prepareSrc.cmd
│   └── runGulp.cmd
├── host.json
└── messages
    ├── .vscode
    │   └── launch.json
    ├── function.json
    ├── index.js
    └── package.json

$ cd messages/
$ code .

npm モジュールインストール

以下の2つのモジュールだけ追加で使います。 Azure Storage Tableへのアクセスと、Address情報のIDを生成するために使います。

$ npm i
$ npm i save azure-storage
$ npm i save object-hash

bot-src/messages/chatuser.js

ChatUserテーブルとやりとりするためのクラスを作ります。

ソースコードはこちらです。

'user strict';
const azure_storage = require('azure-storage');
const hash = require('object-hash');

module.exports = class ChatUser {
  constructor(storageConnectionString) {
    this.client = new azure_storage.TableService(storageConnectionString);
    this.tableName = 'ChatUser';
    this.keyAddress = 'Address';
    this.keyName = 'Name';
  }

  _getAddressId(address) {
    return hash(address.channelId + address.user.id + address.user.name + address.serviceUrl);
    //return [address.channelId, address.user.id, address.user.name, address.serviceUrl].join('_');
  }

  getAddress(name, callback) {
    this.client.retrieveEntity(this.tableName, this.keyAddress, name, (err, entity, response) => {
      if (entity) {
        callback(err, entity.Address['_']);
      } else {
        callback(err, null);
      }
    });
  }

  getHandle(address, callback) {
    let id = this._getAddressId(address);
    this.client.retrieveEntity(this.tableName, this.keyName, id, function(err, entity, response) {
      callback(err, entity ? entity.Handle['_'] : null);
    });
  }

  insertOrReplace(name, address, callback) {
    let updateNameTask = {
      PartitionKey: this.keyName,
      RowKey: this._getAddressId(address),
      Handle: name
    };
    this.client.insertOrReplaceEntity(this.tableName, updateNameTask, (err, result, response) => {
      if (err) callback(err, err.message);
      else {
        let updateAddrTask = {
          PartitionKey: this.keyAddress,
          RowKey: name,
          Address: JSON.stringify(address)
        };
        this.client.insertOrReplaceEntity(this.tableName, updateAddrTask, (err, result, response) => {
          if (err) callback(err, err.message);
          else callback(null, null);
        });
      }
    });
  }
};

bot-src/messages/index.js

処理フローのに沿って動くようにBot本体も開発します。

'use strict';
const builder = require('botbuilder');
const botbuilder_azure = require('botbuilder-azure');
const path = require('path');
const ChatUser = require('./chatuser');

let storageConnectionString = process.env['AzureWebJobsStorage'];
let useEmulator = process.env.NODE_ENV == 'development';

let botStorage = new botbuilder_azure.AzureBotStorage({ gzipData: false }, new botbuilder_azure.AzureTableClient('botdata', storageConnectionString));
let connector = useEmulator
  ? new builder.ChatConnector()
  : new botbuilder_azure.BotServiceConnector({
      appId: process.env['MicrosoftAppId'],
      appPassword: process.env['MicrosoftAppPassword'],
      openIdMetadata: process.env['BotOpenIdMetadata']
    });

let chatUser = new ChatUser(storageConnectionString);
let bot = new builder.UniversalBot(connector);
bot.localePath(path.join(__dirname, './locale'));
bot.set('storage', botStorage);

bot.on('conversationUpdate', session => {
  if (session.membersAdded && session.membersAdded[0].id !== session.address.bot.id) {
    bot.beginDialog(session.address, '/');
  }
});

bot.dialog('/', [
  session => {
    session.beginDialog('init');
  }
]);

bot.dialog('end', [
  session => {
    session.clearDialogStack();
    session.endConversation('bye');
  }
]);

bot.dialog('error', [
  (session, arg) => {
    session.endConversation(`エラーが発生しました\n${arg}`);
  }
]);

bot.dialog('init', [
  session => {
    chatUser.getHandle(session.message.address, (err, handle) => {
      if (err && err.statusCode !== 404) session.beginDialog('error', err.message);
      else {
        if (handle) {
          session.send(`こんにちは、${handle} さん`);
          session.replaceDialog('sendMessage');
        } else {
          session.send('はじめまして');
          session.replaceDialog('registUser');
        }
      }
    });
  }
]);

bot.dialog('registUser', [
  session => {
    builder.Prompts.text(session, 'あなたの呼び方を教えてください');
  },
  (session, results) => {
    chatUser.getAddress(results.response, (err, entity) => {
      if (err && err.statusCode !== 404) session.beginDialog('error', err.message);
      if (entity) {
        session.send(`${results.response}さんは既に登録されています。他の呼び方を教えてください。`);
        session.replaceDialog('registUser');
      } else {
        chatUser.insertOrReplace(results.response, session.message.address, (err, message) => {
          session.replaceDialog('init');
        });
      }
    });
  }
]);

bot.dialog('sendMessage', [
  session => {
    if (session.message && session.message.value) processPostAction(session);
    else builder.Prompts.text(session, '誰にメッセージを送りますか?');
  },
  (session, result) => {
    if (session.message.text === 'bye') {
      session.endConversation('bye');
    } else {
      let handle = result.response;
      chatUser.getAddress(handle, (err, address) => {
        if (err && err.statusCode !== 404) session.endConversation(err.message);
        if (!address) {
          session.send(`ハンドルネームは${handle}さんは登録されていません`);
          session.replaceDialog('sendMessage');
        } else {
          let addr = JSON.parse(address);
          let card = createAdaptiveCard(addr, handle);
          session.dialogData.recipient = addr;
          let msg = new builder.Message(session).addAttachment(card);
          session.send(msg);
        }
      });
    }
  }
]);

function processPostAction(session) {
  let value = session.message.value;
  let senderAddr = session.message.address;
  let recipientAddr = session.dialogData.recipient;
  switch (value.type) {
    case 'sendMessage':
      chatUser.getHandle(senderAddr, (err, handle) => {
        if (err) session.endConversation(err, err.message);
        let title = `${handle} さんからのメッセージが届いています`;
        let card = new builder.HeroCard(session).subtitle(title).text(value.text);
        session.send(new builder.Message().address(recipientAddr).addAttachment(card));
        session.send('メッセージを送信しました');
      });
      break;
    case 'cancelMessage':
      session.endConversation('キャンセルしました。\n会話を終了します。');
      break;
    default:
      session.beginDialog('error', '予期しないエラー');
  }
}

function createAdaptiveCard(address, handle) {
  let card = {
    contentType: 'application/vnd.microsoft.card.adaptive',
    content: {
      $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
      type: 'AdaptiveCard',
      version: '1.0',
      body: [
        {
          type: 'Container',
          items: [
            {
              type: 'TextBlock',
              text: '宛先情報',
              size: 'medium'
            },
            {
              type: 'FactSet',
              facts: [
                {
                  title: 'Channel ID:',
                  value: address.channelId
                },
                {
                  title: 'Handle:',
                  value: handle
                }
              ]
            }
          ]
        },
        {
          type: 'Input.Text',
          id: 'text',
          isMultiline: true,
          placeholder: 'メッセージを入力してください'
        }
      ],
      actions: [
        {
          type: 'Action.Submit',
          title: '送信',
          speak: '<s>Send</s>',
          data: {
            type: 'sendMessage'
          }
        },
        {
          type: 'Action.Submit',
          title: 'キャンセル',
          speak: '<s>Cancel</s>',
          data: {
            type: 'cancelMessage'
          }
        }
      ]
    }
  };
  return card;
}

if (useEmulator) {
  let restify = require('restify');
  let server = restify.createServer();
  server.listen(3978, function() {
    console.log('test bot endpont at http://localhost:3978/api/messages');
  });
  server.post('/api/messages', connector.listen());
} else {
  module.exports = connector.listen();
}

テスト

ローカルPCでエミュレータ使ったテストします。

Azure Storage Table接続設定

実際のストレージテーブルの中身も見ていきたいので、先程作成したストレージへの接続文字列取得します。

$ az storage account show-connection-string --name brokerbotstorage

{
  "connectionString": "DefaultEndpointsProtocol=https;EndpointSuffix=core.windows.net;AccountName=brokerbotstorage;AccountKey=xxxxxxxxxxxxxxxxxxxxxxxxxx=="
}

これを .vscode/launch.json にAzureWebJobsStorageという名前の環境変数として追加します。

      "cwd": "${workspaceRoot}",
      "env": {
        "NODE_ENV": "development",
        "AzureWebJobsStorage": "DefaultEndpointsProtocol=https;EndpointSuffix=core.windows.net;AccountName=brokerbotstorage;AccountKey=xxxxxxxxxxxxxxxxxxxxxxxxxx=="
      }

テスト実行

後はVSCodeからF5を押せばテスト開始です。VSCodeデバッグコンソールにメッセージが以下のようなメッセージが表示されエミュレータの受信待ちになります。

/usr/local/bin/node --inspect-brk=42386 index.js 
Debugger listening on ws://127.0.0.1:42386/9df1afa2-ebce-4793-9134-8552390ae2a3
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.
test bot endpont at http://localhost:3978/api/messages
index.js:186

テストしているところです。自分にメッセージ送信してみてます。

f:id:yomon8:20180920183709p:plain

デプロイ

この方法でデプロイしました。

Azure Bot ServiceのFunctionsでNode.jsコード変更が反映されないのでデプロイ方法調べた - YOMON8.NET

こんな感じでpackage.json作っておくと便利です。

$ cat ../bot-src/package.json 
{
  "scripts": {
    "build": "funcpack pack -c .",
    "deploy": "az bot publish -g broker-bot-rg -n broker-bot"
  }
}

動作確認

TeamsのユーザからSlackのユーザにBotが仲介となってメッセージ届けることができています。

f:id:yomon8:20180920221219g:plain

補足

Slackとの接続

Teamsとの接続は特に何も見ないでもあっさりできますが、Slackは少し手順が必要で以下の手順に従っていきます。

docs.microsoft.com

少しリッチなUIを作れるAdaptiveCard

これは普通のメッセージの代わりにAdaptiveCardというやつを送っているので、こういった少しリッチなUIになります。

f:id:yomon8:20180920221902p:plain

注意

見た目良いのですが、全てのチャネルに対応しているわけではないです。例えば上記と同じCardでもSlackではこんな感じでただの画像になってしまいメッセージ入力する部分が機能しません。

f:id:yomon8:20180920221910p:plain

参考情報
  • AdaptiveCardのホームページ

http://adaptivecards.io/

  • Visualizer(画面で見ながらUIを開発できる)

Visualizer | Adaptive Cards

GitHub - Microsoft/AdaptiveCards: A new way for developers to exchange card content in a common and consistent way.

他にも色々UI部品

AdaptiveCardの他にも色々なUIが準備されています。

https://github.com/Microsoft/BotBuilder-Samples/tree/master/Node/cards-RichCards

役立つ資料

冒頭の自習書と合わせて、こちらのGithubにあるサンプルがとても役立ちます。是非一読してみることをオススメします。(C#とNode.jsの両方あります)

github.com

所感

本当にこれがベストの方法とは思えない感じなので別の方法見つかったら、また更新します。

関連項目が色々触れたので良かったということにします。