React + Express + Watson Assistantで自動応答ボットを作る

## この記事を書いたきっかけ
今まで何となく避けて通ってきた`React`を勉強する事になりました。
せっかくなので何か成果物を作りたいなーと思ったので、Watsonを使った自動応答ページを作ってみる事に

## 完成品
[React側のソース(github)](https://github.com/motuo1201/react-bot)

![sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154110/ff44bacb-e256-808f-4b3e-18432a8676e0.gif)

## 構成
ザックリ言うと、フロントは`React`、APIを呼び出すサーバには`Express`を使っています。
![構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154110/92c4ed0c-a6bf-bc7e-e7d2-3f34c78ded9e.png)

デモにあったの天気の取得は、裏側で`Assistant`⇒`IBM Cloud Functions`⇒`The Weather Company Data`という小技を使っています。
関連エントリ
* [Watson AssistantからIBM Cloud functionsを使う時の考慮点](https://qiita.com/motuo/items/37fff359d0587e5a446b)

## React側の構成

### package.json(一部抜粋)

```json
  "dependencies": {
    "bootstrap": "^4.3.1",
    "dotenv": "^8.0.0",
    "react": "^16.8.6",
    "react-bootstrap": "^1.0.0-beta.9",
    "react-dom": "^16.8.6",
    "react-icons": "^3.7.0",
    "react-loading-overlay": "^1.0.1",
    "react-scripts": "3.0.1",
    "react-spinners": "^0.5.4"
  },
```

1. react-bootstrap  
    なれたbootstrapでフロントを開発したかったので利用
2. `react-spinners`と`react-loading-overlay`  
    問合せ中のローディング画面用のパッケージ
3. dotenv  
    APIサーバ等を`env`ファイルに持たせるためのパッケージ

### ソースコード
index.html

```html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />
    <title>React App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <div id="inputLabel"></div>    
  </body>
</html>
```
App.js(実際の入出力フォーム)

```javascript
import React from 'react';
import ReactDOM from 'react-dom';
import './App.css';
import { FaRobot } from 'react-icons/fa';
import LoadingOverlay from 'react-loading-overlay';
import ScaleLoader from 'react-spinners/ScaleLoader'

function App() {
  /**
   * 入力テキストとボタンをrenderするコンポーネント
   */
  class Input extends React.Component{
    constructor(props){
      super(props);
      this.state = {
        textValue  : "",
        messages   : [],
        session_id : "",
        isActive : false
      }
      this.handleClick = this.handleClick.bind(this);
      this.changeText  = this.changeText.bind(this);
      this.createWatsonAssistantSession();
    };
    /**
     * Watson AssistantのセッションIDを取得して初期メッセージを表示する
     */
    createWatsonAssistantSession(){
      fetch(process.env.REACT_APP_API_SERVER + "/create-session", {
        mode: 'cors'
      })
      .then((response) => {
        return response.json().then(res=>{
          this.setState({
            session_id : res.session_id
          })
          this.fetchAssistant();
        });
      })
      .then((error)=>{
        return error;
      });
    }
    fetchAssistant(){
      fetch(process.env.REACT_APP_API_SERVER + "/conversation", {
        mode  : 'cors',
        method: 'POST',
        body : JSON.stringify({ session_id : this.state.session_id, inputText : this.state.textValue }),
        headers : new Headers({ "Content-type" : "application/json" })
      })
      .then((response) => {
        let conversation = [];
        const question = {kind: "question",text: this.state.textValue};
        return response.json().then(res=>{
          let answer;
          if(res.output.generic[0]){
            answer   = {kind: "answer",text: res.output.generic[0].text};
          }else{
            answer   = {kind: "answer",text: "答えがありません"};
          }
          conversation.push(question,answer);
          this.setState({
            messages : this.state.messages.concat(conversation)
          });
          this.setState({
            isActive:false
          });
    
        });
      })
      .then((error)=>{
        return error;
      });  
    }
    handleClick(){
      this.setState({
        isActive:true
      });
      this.fetchAssistant();
    };
    changeText(e){
      this.setState({
        textValue: e.target.value
      });
    };
    render(){
      return (
        <div className="container">
            <LoadingOverlay active={this.state.isActive} spinner={<ScaleLoader /> } text='Watsonに問合せ中…'>
            <div className="row">
              <h3 className="offset-md-3 text-danger"><FaRobot /> Reactボット</h3>
            </div>
            <div className="row">
              <div className="offset-md-3 col-md-6">
                <input type="text" className="form-control" placeholder="入力してみてね" value={this.state.textValue}
                  onChange={this.changeText}></input>
              </div>
              <div className="col-md-2">
                <button className="btn btn-primary" onClick={this.handleClick}>Talk To Watson</button>
              </div>
            </div>
          </LoadingOverlay>
          <OutputLabel messages={this.state.messages} />
        </div>
      );
    }
  }
  /**
   * 会話の内容をラベルとして表示する
   */
  class OutputLabel extends React.Component{
    /**
     * 発信なのかbotからの応答なのかを判定してrenderする文字列を返す
     * @param {*} message 
     * @param {*} index 
     */
    determineClass(message,index){
      if(message.text===""){
        return;
      }
      let   bg    = (message.kind === "question")?"offset-md-3 col-md-5 bg-warning":"offset-md-4 col-md-5 bg-success";
      const actor = (message.kind === "question")?"あなた":"bot"
      return(
        <div className="row" key={index} >
          <div className={bg}>
            <label className="control-label" >
              {actor}：{message.text}
            </label>
          </div>
        </div>);
    };
    render(){
      const labels = this.props.messages.map((message, index) => {
        return this.determineClass(message,index);
      });
      return(
        <div>
         {labels}
       </div>
      );
    }
  }
  return ReactDOM.render(<Input />, document.getElementById('root'));
}

export default App;
```

fetch部分を`axios`に変えるともう少し記述はシンプルになります。

```javascript
    /**
     * Watson AssistantのセッションIDを取得して初期メッセージを表示する
     */
    createWatsonAssistantSession(){
      axios.get(process.env.REACT_APP_API_SERVER + "/create-session")
      .then((response) => {
        this.setState({
          session_id : response.data.session_id
        })
        this.fetchAssistant();
        return;
      })
      .then((error)=>{
        return error;
      });
    }
    fetchAssistant(){
      let params = new URLSearchParams();
      params.append('session_id',this.state.session_id);
      params.append('inputText',this.state.textValue);
      axios.post(process.env.REACT_APP_API_SERVER + "/conversation",params)
      .then((response) => {
        let conversation = [];
        const question = {kind: "question",text: this.state.textValue};
        let answer;
        if(response.data.output.generic[0]){
          answer   = {kind: "answer",text: response.data.output.generic[0].text};
        }else{
          answer   = {kind: "answer",text: "答えがありません"};
        }
        conversation.push(question,answer);
        this.setState({
          messages : this.state.messages.concat(conversation)
        });
        this.setState({
          isActive:false
        });  
      })
      .then((error)=>{
        return error;
      });  
    }
```

### 環境変数(`.env)`
.env.example

```
# API Server for Watson Call
REACT_APP_API_SERVER=http://localhost:3010/watson/assistant
```

## Express側

### package.json(一部抜粋)
`dotenv`と`ibm-watson`を追加

```json
  "dependencies": {
    "body-parser": "~1.18.2",
    "cookie-parser": "~1.4.3",
    "cors": "^2.8.5",
    "debug": "~2.6.9",
    "dotenv": "^8.0.0",
    "ejs": "~2.5.7",
    "express": "~4.15.5",
    "ibm-watson": "^4.2.1",
    "morgan": "~1.9.0",
    "serve-favicon": "~2.4.5",
    "watson-developer-cloud": "^4.0.1"
  }
```


### app.js
cors対策とwatson用のroutesを追記

```javascript
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var cors = require('cors');

var index = require('./routes/index');
var users = require('./routes/users');
var watson = require('./routes/watson');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', index);
app.use('/users', users);
//ここを追記
app.use(cors())
app.use('/watson', watson);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;
```

### routes

今回は手抜きで直接routeの中に処理を書きましたw
[公開されているAPIリファレンス](https://cloud.ibm.com/apidocs/assistant-v2)のまんま、
新規セッション作成と会話用の受け口を作成

```javascript
var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/assistant/create-session', function(req, res, next) {
  const AssistantV2 = require('ibm-watson/assistant/v2');

  const service = new AssistantV2({
    iam_apikey: process.env.REACT_APP_ASSISTANT_IAM_APIKEY,
    version: '2019-06-14',
    url: process.env.REACT_APP_ASSISTANT_URL
  });

  service.createSession({
    assistant_id: process.env.REACT_APP_ASSISTANT_ID
  })
  .then(watson_response => {
    console.log(JSON.stringify(watson_response, null, 2));
    res.send(JSON.stringify(watson_response, null, 2));
  })
  .catch(err => {
    console.log(err);
  });
});

router.post('/assistant/conversation', function(req, res, next) {
  const AssistantV2 = require('ibm-watson/assistant/v2');

  const service = new AssistantV2({
    iam_apikey: process.env.REACT_APP_ASSISTANT_IAM_APIKEY,
    version: '2019-02-28',
    url: process.env.REACT_APP_ASSISTANT_URL
  });
  
  service.message({
    assistant_id: process.env.REACT_APP_ASSISTANT_ID,
    session_id: req.body.session_id,
    input: {
      'message_type': 'text',
      'text': req.body.inputText
      }
    })
    .then(watson_response => {
      res.send(JSON.stringify(watson_response, null, 2));
    })
    .catch(err => {
      console.log(err);
    });
});

module.exports = router;

```

### 環境変数(.env)

.env.example

```
# Environment variables
PORT=3010
REACT_APP_ASSISTANT_ID="XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
REACT_APP_ASSISTANT_IAM_APIKEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
REACT_APP_ASSISTANT_URL=https://gateway.watsonplatform.net/assistant/api
```

## ハマったポイント
1. CORS対策
    `React`側から通信をすると、CORSでエラーとなってしまいました。対策として`Express`側のサーバでCORS対策として、`cors`をインストール
2. React側で環境変数が読めない！
    よくあるケースの様で、環境変数の先頭に`REACT_APP_`をつける必要があるようです。`Express`側では不要ですが、今回は平仄を合わせる意味でこちらの環境変数名にも`REACT_APP_`をつけています。

## Reactを触った感想
今回はチュートリアルをザっと眺めてからトライしてみましたが、思ったよりも簡単に実装をする事が出来ました。
特に各コンポーネントを部品として扱うので、再利用性や他のReactのパッケージ組み込みが簡単で、今までよりもリッチな画面を作りやすいかな…という感じです。(この機能の実装にかかったのは4h程度)
一方で設計をしっかりしないと、いつどこで`state`に値が設定されているか分かり辛くなったり、コンポーネントを重複して開発しそうだなぁ。。。