やりたいことタイトルで伝わるか微妙ですが、要は以下の図のようにAzure Bot Serviceで仲介して複数のチャネルの人を繋げるやり方が無いかと調べ始めました。
そこで見つけた、このあたりのIssueを読むと、できそうな気がしたので実際にやってみました。
基本
Azure Bot Service基礎的なことは、こちらの記事には書くまでもなく、この自習書が丁寧にまとめられているので、とても役立つと思います。自習書はC#なのでNode.jsで開発する人は適宜読み替えが必要ですが。
◆【Microsoft Azure 自習書シリーズ】Cognitive Services と Bot Service で作る業務アプリケーション
イメージ
処理フロー
ざっくりの処理フローはこのようなものを想定しています。
Bot Frameworkでは、Addressという属性があれば、特定のユーザにメッセージが送信できます。
実際には一人のユーザでも複数のチャネル(Address)を使っていることを想定して、BotとしてユーザのAddressをユニークに識別するHandleという属性持つ形にしてみました。
初回アクセス時にHandleを登録する仕様です。
テーブル
HandleからAddress引く場合と、アドレスからハンドルネーム引く場合が考えられるので、以下のようなChatUserテーブルを作成してみます。
以下を参考にしています。
環境構築
開発環境準備
大前提としては私の開発環境は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からやりました。以下の設定の通りです。
テーブル作成
上述のテーブル作成します。
$ 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
テストしているところです。自分にメッセージ送信してみてます。
デプロイ
この方法でデプロイしました。
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が仲介となってメッセージ届けることができています。
補足
Slackとの接続
Teamsとの接続は特に何も見ないでもあっさりできますが、Slackは少し手順が必要で以下の手順に従っていきます。
少しリッチなUIを作れるAdaptiveCard
これは普通のメッセージの代わりにAdaptiveCardというやつを送っているので、こういった少しリッチなUIになります。
注意
見た目良いのですが、全てのチャネルに対応しているわけではないです。例えば上記と同じCardでもSlackではこんな感じでただの画像になってしまいメッセージ入力する部分が機能しません。
参考情報
- AdaptiveCardのホームページ
- Visualizer(画面で見ながらUIを開発できる)
他にも色々UI部品
AdaptiveCardの他にも色々なUIが準備されています。
https://github.com/Microsoft/BotBuilder-Samples/tree/master/Node/cards-RichCards
役立つ資料
冒頭の自習書と合わせて、こちらのGithubにあるサンプルがとても役立ちます。是非一読してみることをオススメします。(C#とNode.jsの両方あります)
所感
本当にこれがベストの方法とは思えない感じなので別の方法見つかったら、また更新します。
関連項目が色々触れたので良かったということにします。