reactjs
BotFramework
chatbot
SENSYDay 20

BotFramework "WebChat" の歩き方

はじめに

Microsoftの提供する BotFrameworkでは、Bot Builder SDKを使ってBotサーバを書くと、Bot Connector Serviceが仲立ちして各Channel (Facebook Messenger, Slack, Skype etc) を通してbotと会話できるようになるのですが、そのChannelの1つに 「WebChat」(webサイトに導入できるwidget)があります。

本記事では、WebChatの導入方法とカスタマイズ方法について書きます。
(Botサーバ側の開発については他の記事を参照下さい)
architecture-resize.png
(https://docs.microsoft.com/en-us/bot-framework/media/how-it-works/architecture-resize.png)

WebChatを導入する方法

  • WebChat自体はReact-Redux製のOSSとしてこちら で公開されているのですが、以下に書くように自前でソースをbuild & ホスティングせず、Microsoftがホスティングするものを使って導入することもできます。

  • 導入法1: iframeをサイトに埋め込む

    • Botの管理ページ に「WebChat」Channelを追加し、表示される「Embed code」をコピーしてサイトに埋め込む
    • Embed codeの例 (シークレットはBotの管理ページから確認できます)
<iframe src='https://webchat.botframework.com/embed/{Bot名}?s={シークレット}></iframe>
  • 導入法2: iframeは使いたくない場合
    • 以下のように、WebChatの描画領域のDOMを渡す方法があります。
<!DOCTYPE html>
<html>
  <head>
    <link href="https://cdn.botframework.com/botframework-webchat/latest/botchat.css" rel="stylesheet" />
  </head>
  <body>
    <div id="bot"/>
    <script src="https://cdn.botframework.com/botframework-webchat/latest/botchat.js"></script>
    <script>
      BotChat.App({
        directLine: { secret: 'シークレット' },
        user: { id: 'userid' },
        bot: { id: 'botid' },
        resize: 'detect'
      }, document.getElementById("bot")); // ★chatを描画させたい領域のDOMを渡す
    </script>
  </body>
</html>

  • 導入法3: Reactサイトに導入する
    • npm install botframework-webchat した上で、以下のようにComponentとして追加することが出来ます
import { Chat } from 'botframework-webchat';

...

const YourApp = () => {
    <div>
        <YourComponent />
        <Chat directLine={{ secret: 'シークレット' }} user={{ id: 'user_id', name: 'user_name' }}/>
        <YourOtherComponent />
    </div>
}
...

その他詳細は README をご覧ください。

WebChatのカスタマイズ方法

開発フロー

# まずは環境構築
git clone https://github.com/Microsoft/BotFramework-WebChat.git
npm install

# => ソースを修正する

npm run build # => ./botchat.js, ./botchat.cssが吐き出される
npm run start # 開発サーバ起動

# => ブラウザで http://localhost:8000/samples/sidebar/index.html?s={シークレット} 
#    にアクセスしすると、 ./botchat.js, ./botchat.css を読み込んでchatが表示される

localのwidgetをlocalのBotサーバに接続させたい場合は?

以下のように、Bot Connector Serviceが参照するBotをngrokでlocalにポートフォワードすることで可能になります

# 上記手順でwidgetのサーバは起動した状態で

# 1. Botサーバを起動(Node.jsの場合)
npm run start # => port:3978でBotサーバが起動する

# 2. 別ターミナルからngrokを起動
ngrok http 3978 # => 「Forwarding  https://aaabbbccc999.ngrok.io -> localhost:3978」をメモっておく

# 3. https://dev.botframework.com/bots から対象のBotを選択 -> 
#    右上の「SETTINGS」 -> 「Messaging endpoint」を 2のURLに変更して保存
#    【※注意】2018年3月頃にMS Bot Frameworkは Azure Bot Serviceに統合されることが
#     アナウンスされているため、ここの手順は将来変わると思われます。

# 4. http://localhost:8000/... にアクセスしてBotと会話すると、localのBotサーバにつながります

見た目を変えたい場合

  • ヘッダーの色を変えたい、文字サイズを変えたい 程度であれば src/scss/botchat.scss を修正 & buildしたものをホスティングして読み込ませればokです

機能を修正したい場合

  • WebChat本体のソースコードを修正することになりますが、Bot Connector Serviceを介してBotサーバとやりとりをするという関係上、通信部分には手を出せない ( Direct Line API の制約を受ける) ことに注意が必要です。
    • Bot Connector Serviceを介さず、直接BotサーバのAPIを叩くんや!となれば話は別ですが、WebChat以外のChannelとの機能差異が生まれることになります。
    • DirectLineAPIとのやりとりは、BotFramework-DirectLineJSというライブラリとして提供されています。
  • ソースを読むにあたっては、TypeScript,React,Redux,redux-observable, RxJSあたりの知識が必要になります。(多い・・)

以下では、WebChat本体を修正するのに必要な知識についてざっくりまとめます。

ファイル構成

.
├── package-lock.json
├── package.json
├── samples # 動作確認用のHTML等が入っている
│   ├── backchannel
│   ├── fullwindow
│   ├── react
│   ├── sidebar
│   └── speech
├── src # コンポーネントの各ファイルは、ここに1階層(フラット)に配置されている
│   ├── ActivityView.tsx
│   :         :
│   ├── SpeechOptions.ts
│   ├── Store.ts   # store
│   ├── Strings.ts # localize文言はここ
│   ├── Timestamp.tsx
│   ├── Types.ts
│   ├── adaptivecards-hostconfig.d.ts
│   ├── getTabIndex.ts
│   └── scss # 見た目を変えたい場合はこの中のscssをいじる
├── test
├── tsconfig.json
├── webpack.config.js
└── webpack.production.config.js

ストア構成

action creator, state, reducerは src/Store.tsに定義

state名 役割
shell ユーザの入力したテキストを保持
format locale情報、localize用のテキストマスタを保持
size widgetのwidth, heightを保持
connection Bot Connector Serviceへのconnectionや、ユーザ/Botのidentity情報を保持
history Bot-ユーザ間でやりとりしたActivity(=メッセージや、タイピング中であることを通知するイベントetc)を保持する

createStoreは以下のようになっており、副作用を伴うものはEpicとして定義してMiddlewareにして混ぜ込む。
(Epic: actionのストリーム(=Observable)を受け取ってaction(Observable)を返す関数。例えば、ユーザがメッセージを入力して送信ボタンを押すと、Shell Actionの Send_Messageが発火 → それをsendMessageEpicでhookして state.history.botConnection.postActivity でBotConnector Serviceに投げる → 成功したら Send_Message_Succeed actionを返す という感じで使われる)

export const createStore = () =>
    reduxCreateStore(
        combineReducers<ChatState>({
            shell,
            format,
            size,
            connection,
            history
        }),
        applyMiddleware(createEpicMiddleware(combineEpics(
            updateSelectedActivityEpic,
            sendMessageEpic,    # <- 上記に書いたメッセージの送信は
            trySendMessageEpic, # <- これらで行う
            retrySendMessageEpic,
            showTypingEpic,
            sendTypingEpic,
            speakSSMLEpic,
            speakOnMessageReceivedEpic,
            startListeningEpic,
            stopListeningEpic,
            stopSpeakingEpic,
            listeningSilenceTimeoutEpic
        )))
    );

コンポーネント構成

  • src/App.tsxがReactアプリケーションを表現、src/Chat.tsxがTopレベルのコンポーネント
  • 見た目上はざっくり以下のような対応になる quiz2.png

○○したい時はこうしよう

ページ遷移しても同じ会話を継続したい

BotChat.Appのコンストラクタに渡す directLineオプションに conversationId というものがあり、これにページ遷移前のconversationIdを渡してchatを起動すると、会話を継続することができます。

BotChat.App({
    directLine: {
        secret: params['s'] || envToSecret[env],
        token: params['t'],
        domain: params['domain'],
        webSocket: isWebSocket,
        conversationId: 'hogehoge', // ★ここに渡す
    },
    user: user,
    bot: bot,
        :

(遷移前のページでの)conversationIdはcookie等に保存すると良いかと思います。
保存するタイミングとしては、BotからのActivityを受け取る箇所で行えば良いでしょう。

# https://github.com/Microsoft/BotFramework-WebChat/blob/master/src/Chat.tsx

diff --git a/src/Chat.tsx b/src/Chat.tsx
index 8e6a8c9..af17109 100644
--- a/src/Chat.tsx
+++ b/src/Chat.tsx
@@ -78,7 +78,14 @@ export class Chat extends React.Component<ChatProps, {}> {
         let state = this.store.getState();
         switch (activity.type) {
             case "message":
-                this.store.dispatch<ChatActions>({ type: activity.from.id === state.connection.user.id ? 'Receive_Sent_Message' : 'Receive_Message', activity });
+                let is_sent_message = activity.from.id === state.connection.user.id ? true : false;
+                this.store.dispatch<ChatActions>({ type: is_sent_message ? 'Receive_Sent_Message' : 'Receive_Message', activity });
+                if (is_sent_message) {
+                    break;
+                }
+                // 例えばこんな感じでconversationIdを保存
+                document.cookie = 'conversationId=' + activity.conversation.id;
+
                 break;

             case "typing":

Bot-Widget間で独自のメッセージ交換やイベント通知をしたい

  • 両者で交わされるActivityオブジェクト(メッセージやTyping中であることのイベント通知)に entityというプロパティ(anyの配列)があり、そこは自由に使えるようです。
    • 使うシーンとしては、entity経由でwidgetからBotにlocation.hrefを通知して、chatが起動されたページによってBotの応答を変える、など色々考えられそうです。
    • entityはBot Connector Serviceを経由する、かつキャッシュされる(上に書いたconversationIdを渡してchatを起動した際は、キャッシュされたactivityがConnector Serviceから送られてくる)ため、外部サービスに渡して良い情報なのか?という点は考慮が必要かと思います。
  • Bot側(Node.js SDK)のReferenceは これこれです。 以下のような感じでentityを参照/送信できます。
var builder = require('botbuilder');

function(session, args){
    // ユーザから送られたentitiesを参照
    var entities = session.messsage.entities;
                 :
    // ユーザにentitiesを送る
    var entity = {hoge: 'fuga'};
    var message = new builder.Message(session)
        .text('hoge')
        .addEntity(entity)
    session.send(message)
  • widget側では以下のようにして参照します。
// 例. ActivityView
this.props.activity.entities

  • widget側からentityを送る方法は未実装のようですが、以下の様な改修すると送ることができます。(本家へのPR検討しよう・・)
diff --git a/src/Store.ts b/src/Store.ts
index 13da314..56963cf 100644
--- a/src/Store.ts
+++ b/src/Store.ts
@@ -9,7 +9,7 @@ import * as konsole from './Konsole';

 import { Reducer } from 'redux';

-export const sendMessage = (text: string, from: User, locale: string) => ({
+export const sendMessage = (text: string, from: User, locale: string, entities: any[] = []) => ({
     type: 'Send_Message',
     activity: {
         type: "message",
@@ -17,7 +17,8 @@ export const sendMessage = (text: string, from: User, locale: string) => ({
         from,
         locale,
         textFormat: 'plain',
-        timestamp: (new Date()).toISOString()
+        timestamp: (new Date()).toISOString(),
+        entities: entities
     }} as ChatActions);

 export const sendFiles = (files: FileList, from: User, locale: string) => ({
  • sendMessageを使う側
import 'sendMessage' from 'path/to/Store';

const entities = [{hoge: 'fuga'}];
sendMessage('message', user, locale, entities);

以上です。素敵なchatbot生活をお過ごし下さい :innocent: