ChatWorkのWebhook+GASで担当者に自動タスク化

どうもこんにちは。
株式会社サイトビジットの横沢です。

弊社ではチャットツールとして`ChatWork`を使っているのですが、
2017年11月1日にChatWorkが[Webhookの提供を開始](http://blog-ja.chatwork.com/2017/11/api-openbeta.html)したということで、CSの作業を自動化してみました。

# 内容
## 困ってたこと

CS担当が電話を受けて担当者が不在の場合、ChatWorkで担当者にTOをつけて送っていたが、

* TOつけるのめんどくさい
* 担当者の対応が抜ける

という問題が発生していました。

## やったこと
### 運用
* `受電メモ窓`を作成して、CSの方にはここにメモって送信してもらうようにした
* `問い合わせ対応窓`を作成して、ビジネスサイドの人はここのタスクをウォッチしてもらうようにした
    * 対応が終了したらタスクを完了してもらった

### システム
* ChatoWorkに適当にメモって送信したら、テキストを解析して担当者のタスクに自動的に追加されるようにした
    * 該当者がいない場合、とりあえずCSの責任者にタスクを追加して、責任者が適宜割り振るようにした


# 詳細
## 事前準備
頑張って書こうと思ったけど[このページ](https://tonari-it.com/gas-chatwork-webhook-message/)見た方が早い。笑

* 適当なスプレッドシートからGASを作成して、ウェブアプリケーションのURLを取得
    * とりあえず以下のコードがあればWebhookを受け取れる（Google様ありがとう）

```javascript
function doPost(e) {
  var json = JSON.parse(e.postData.contents);
  doChatwork(json.webhook_event);
}
```

* ChatWorkの[API Tokenを発行](https://www.chatwork.com/service/packages/chatwork/subpackages/api/token.php)した
    * 弊社は当然のようにビジネスアカウントなので、管理者に`API Token`を発行したので承認してくだされとお願いする必要ありましたが、個人利用なら必要ない（はず）
* [Webhookの設定](https://www.chatwork.com/service/packages/chatwork/subpackages/webhook/list.php)
    * 現時点（2018年3月10日）だと、`メッセージ作成`と`メッセージ更新`のみだが、今回は`メッセージ作成`をチョイス
    * ルームIDは`受電メモ窓`のIDを入れる
* `log`という名前のシート作成
* 社員のデータをスプレッドシートに準備
    * `Member`というシート名にする
    * これもGASで取ってきたけど今回は割愛（体力の限界）

<img width="100%" alt="610f8531dc191c21e6abe1c67b3e74f6.png" src="https://qiita-image-store.s3.amazonaws.com/0/75102/51c25e0b-d36a-8660-7a57-876b3111ac29.png">

|列名|意味|
|---|---|
|name|社員の名前|
|chatwork_id|社員のChatWorkID|
|condition|適当な単語をカンマ（,）区切りで（この単語がメモに入ってたら担当者になる）|
|guardian|担当者が該当しない場合に `TRUE` の人が担当者になる|

単語の意味とかは気にしないでください（英語できない）

こんな感じで準備終了。`受電メモ窓`に何かメッセージ入れればWebhook発動される（はず）

## 利用するシートの準備

```javascript
LOG_SHEET = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("log");
MEMBER_SHEET = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Member");
```
なんやかんや色んな所で使いそうだったのでとりあえずグローバル変数に

## ChatWork関連のメソッドをモジュール化（postだけ抜粋）

```javascript
var Chatwork = {
  apiKey: "***** 適宜自分のAPI Key *******",
  apiUrl: "https://api.chatwork.com/v2",

  post: function(apiPath, payload){
    var url = this.apiUrl + apiPath;
    var options = {
      headers: {
        "X-ChatWorkToken": this.apiKey 
      },
      'method': 'POST',
      'payload': payload
    }
    var response = UrlFetchApp.fetch(url, options);
  },

  // タスクを追加
  addTask: function(roomId, message, toIds) {
    var apiPath = "/rooms/" + roomId + "/tasks";
    var payload = {
      body: message,
      to_ids: toIds
    }
    this.post(apiPath, payload);
  }
}
```

`apiPath`と`payload`を変更すれば、新しい窓作ったりメッセージ送信したりできます。

## スプレッドシートのデータを持ってくるメソッド

```javascript
var Common = {
  // ヘッダー情報をとってくる
  getHeaders: function(sheet) {
    var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues();
    return headers[0];
  },
  // ヘッダーと値を突合してハッシュを生成
  getHashFromHeaders: function(headers, items) {
    var hash = {};
    for(var i in headers) {
      hash[headers[i]] = items[i];
    }
    return hash;
  }
}
```
スプレッドシートからデータ持ってくると2次元配列で返ってくるので、扱いがやや面倒です。
なので、1行目のヘッダー情報を配列に付加してオブジェクトに変換する目的。
余裕でリファクタリング対象

```javascript
var Member = {
  // メンバー全員を配列で持ってくる
  getAllList: function(memberSheet) {
    var memberList = memberSheet.getRange(2, 1, memberSheet.getLastRow()-1, memberSheet.getLastColumn()).getValues();
    var headers = Common.getHeaders(memberSheet);
    var members = []
    for(var i in memberList) {
      var member = Common.getHashFromHeaders(headers, memberList[i])
      members.push(member);
    }
    return members;
  },
  // 担当者を選択する
  selectIdList: function(text) {
    var allMembers = this.getAllList(MEMBER_SHEET);
    var selectMember = [];
    for(var i in allMembers) {
      var member = allMembers[i];
      var conditionArr = member.condition.split(',');
      for(var j in conditionArr) {
        if(text.indexOf(conditionArr[j]) <= 0) {
        } else {
          selectMember.push(member.chatwork_id);
        } 
      }
    }
    if(selectMember.length == 0) {
      for(var i in allMembers) {
        var member = allMembers[i];
        if(member.guardian == true) {
          selectMember.push(member.chatwork_id);
        }
      }
    }
    selectMember = selectMember.filter(function (x, i, self) {
      return self.indexOf(x) === i;
    });
    return selectMember;
  }
}
```

この辺ちょっと複雑、、、
要はチャットワークの文章を解析して、担当者のチャットワークIDを配列に入れてるだけ。
スーパーリファクタリング対象

## Webhookの受け取り

```javascript
function doPost(e) {
  var json = JSON.parse(e.postData.contents);
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('log');
  var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues();
  for(var i in headers[0]) {
    i = Number(i)
    sheet.getRange(2, i+1).setValue(json.webhook_event[headers[0][i]]);
  }
  doChatwork(json.webhook_event);
}
```

Webhookを受け取った後に、とりあえず `log`シートに内容を展開してます。（なんでこうしたか忘れたけど） 

## タスクの追加
```javascript

TO_ROOM_ID = /*送りたい窓のID*/;

function doChatwork(details){
  var headers = Common.getHeaders(LOG_SHEET);
  var range = LOG_SHEET.getRange(2, 1, 1, LOG_SHEET.getLastColumn());
  var detail = Common.getHashFromHeaders(headers, range.getValues()[0]);
  if(detail.account_id == OPERATOR_ID) {
    var memberIds = Member.selectIdList(detail.body).join(',');
    Chatwork.addTask(TO_ROOM_ID, detail.body, memberIds);
  }
}
```

Webhookを受け取った後に、これ実行すればタスクが追加されます。

## 全ソース

```javascript

TO_ROOM_ID = /*送りたい窓のID*/;
LOG_SHEET = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("log");
MEMBER_SHEET = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Member");


var Chatwork = {
  apiKey: "***** 適宜自分のAPI Key *******",
  apiUrl: "https://api.chatwork.com/v2",

  post: function(apiPath, payload){
    var url = this.apiUrl + apiPath;
    var options = {
      headers: {
        "X-ChatWorkToken": this.apiKey 
      },
      'method': 'POST',
      'payload': payload
    }
    var response = UrlFetchApp.fetch(url, options);
  },

  // タスクを追加
  addTask: function(roomId, message, toIds) {
    var apiPath = "/rooms/" + roomId + "/tasks";
    var payload = {
      body: message,
      to_ids: toIds
    }
    this.post(apiPath, payload);
  }
}

function doPost(e) {
  var json = JSON.parse(e.postData.contents);
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('log');
  var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues();
  for(var i in headers[0]) {
    i = Number(i)
    sheet.getRange(2, i+1).setValue(json.webhook_event[headers[0][i]]);
  }
  doChatwork(json.webhook_event);
}

function doChatwork(details){
  var headers = Common.getHeaders(LOG_SHEET);
  var range = LOG_SHEET.getRange(2, 1, 1, LOG_SHEET.getLastColumn());
  var detail = Common.getHashFromHeaders(headers, range.getValues()[0]);
  if(detail.account_id == OPERATOR_ID) {
    var memberIds = Member.selectIdList(detail.body).join(',');
    Chatwork.addTask(TO_ROOM_ID, detail.body, memberIds);
  }
}

var Common = {
  // ヘッダー情報をとってくる
  getHeaders: function(sheet) {
    var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues();
    return headers[0];
  },
  // ヘッダーと値を突合してハッシュを生成
  getHashFromHeaders: function(headers, items) {
    var hash = {};
    for(var i in headers) {
      hash[headers[i]] = items[i];
    }
    return hash;
  }
}

var Member = {
  // メンバー全員を配列で持ってくる
  getAllList: function(memberSheet) {
    var memberList = memberSheet.getRange(2, 1, memberSheet.getLastRow()-1, memberSheet.getLastColumn()).getValues();
    var headers = Common.getHeaders(memberSheet);
    var members = []
    for(var i in memberList) {
      var member = Common.getHashFromHeaders(headers, memberList[i])
      members.push(member);
    }
    return members;
  },
  // 担当者を選択する
  selectIdList: function(text) {
    var allMembers = this.getAllList(MEMBER_SHEET);
    var selectMember = [];
    for(var i in allMembers) {
      var member = allMembers[i];
      var conditionArr = member.condition.split(',');
      for(var j in conditionArr) {
        if(text.indexOf(conditionArr[j]) <= 0) {
        } else {
          selectMember.push(member.chatwork_id);
        } 
      }
    }
    if(selectMember.length == 0) {
      for(var i in allMembers) {
        var member = allMembers[i];
        if(member.guardian == true) {
          selectMember.push(member.chatwork_id);
        }
      }
    }
    selectMember = selectMember.filter(function (x, i, self) {
      return self.indexOf(x) === i;
    });
    return selectMember;
  }
}

```

# まとめ

初めてGASでポストを受け取ってみてサッと作ったメソッドなので、スーパーリファクタリング対象が多いです。（最初からもっと綺麗に書けるようになりたい）

ただ、運用している際には特に何も影響がないので一旦これで運用しています。

CSは1次受付（対応できるものはする）、深い内容はビジネスサイドというように使い分けがきちんと出来ているので、

* CSの時間短縮
    * 内容から担当者を考える必要なし
    * TOをいちいちつける必要なし
* ビジネスサイドの対応漏れ削減
    * タスクが追加されるのでそれを完了させていけば、あら不思議対応漏れなし
    * タスクの数とかが可視化されているので、タスクが残っていたら相互にフォローする体制が自然とできた


あと、実は1日の終わりの18時にタスクが残っている人に、TOをつけて名前と未対応のタスク数をメッセージ送信するメソッドも作りました。
これによって、「やべえ対応漏れあるやん」「おい、お前対応漏れてるぞ」という感じになりました。

それも紹介しようと思いましたが、ここで力尽きました。
また余裕が出来たら書こうと思います。

他にもGASの便利機能を結構作っているので、それも今後紹介できたらしていきますー
[ツイッター](http://bit.ly/2PkyJHK)もやっているのでお友達になっていただけると嬉しいです。

以上です。