Cloud Functionsで社内Slackコマンド作った


こんにちは．
PLAIDエンジニアインターンのキタボリです．
PLAID Advent Calendar 2018の23日目の記事です！

## 内容
PLAIDは今年の7月からオフィスを銀座へ移転しました．
とても広くて快適なのですが，一部フリーアドレスを採用しているため「どこに誰がいるか分からない！」や，「〇〇さんは出勤してるのかな？」などの声があちらこちらで上がっています．．
そこで今回は，既存の社内リソースを活用して「〇〇さんはオフィスにいるか」を確認するためのSlackコマンドをCloudFunctionsで実装したので投稿したいと思います！

## 概要
- Slacksで`/doko ＠hoge`と送るとhogeさんがオフィスにいるかどうかレスポンスを返すコマンドを実装．
- Cloud Functionsを使いたい！
- オフィスにいるかどうかの判断基準は社内リソースを活用する(後述)．  

![memberSearch.jpg](https://qiita-image-store.s3.amazonaws.com/0/263803/d53d9711-e28c-3bda-be2d-d76004fddb4b.jpeg)
　　
イメージ的にはこんな感じです．
Slackからメンションをつけてコマンドを送るとCloud Functionsで処理してレスポンスを返します．

## 開発環境
今回はある程度までDocker上で動かしたCloud Functionsのemulatorを使って開発しました．本番の環境とは若干異なる(とdeployするたびに言われる)のでご了承ください．
emulatorは`npm install -g @google-cloud/functions-emulator`でインストールできます．
## 実装
### Cloud Functionsの設定
プロジェクトやCloud Functionsの設定は省略します．
今回の開発で主に使ったCloudSDKのコマンドだけ紹介します．  

```bash
$ gcloud beta functions deploy {関数名} --trigger-http --runtime=nodejs8 --set-env-vars --source .
```

デプロイコマンドです．
キモは`--runtime=nodejs8`です．これを指定しないとversion6のnodeで実行してしまうためです．
`--set-env-vars`はCloud Functionsに渡す環境変数を定義する部分です．このオプションを付与すると，`env.yml`に記述した環境変数がプロジェクトに反映されます．
また`--source .`に関しては，最初のデプロイ時には必要ありません．2回目以降のデプロイ時に指定しないと変更が反映されませんので忘れずに付けましょう．　　

```bash
$ gcloud beta functions logs read
```
ログの確認です．

### SlackのSlash commandを登録
特定のWorkspaceで独自のslash commandを使うために，[こちら](https://api.slack.com/apps?new_app=1)からSlash commandを登録します．
開発に必要なものは，SlackAPIからメンバー一覧を参照するために，`OAuth Access Token`と，Select Permission Scopesから`users.read`を選択しました．また，一応認証を行いたいため`verification token`を取得します．

### 中身
今回はオフィス内に特定の社員がいるかどうか検索するコマンドを実装します．
オフィス内にいるかどうかは，社内ツールでネットワークに繋がっているかどうかを1分おきにGoogle Spreadsheetに記録しているので，それを参考にします．
また，上記のスプレッドシートの登録名と，Slackコマンドから送られてくるユーザー名を照合するために，対応表を用いました．


今回のコマンドでは大きく分けて三つの処理をPromiseで実行しています．
1. コマンドからPOSTされたユーザー名から，SlackのAPIを使ってユーザーIDを取得
1. 取得したユーザーIDから勤怠記録登録名を取得
1. 取得した勤怠登録名から最後にonlineだった時間を取得，レスポンスを作成

それぞれ以下がコードになります．
#### ユーザーIDの取得
Slackコマンドで`/doko ＠kitabori`のようにメンションを引数に渡すと，このメンション部分は`@ユーザー名`のように解決されます．このユーザー名とは，おそらくデフォルトでは登録したメールアドレスのアカウント部分なのですが，アカウント設定から変更することができます．
僕はてっきり変更できるものだとは知らずにいたので，全員メールアドレスのアカウント部分なのかと思い，ここでかなり躓きました．．
ここで対応表と照らし合わせるためにユーザーIDが必要になったため，SlackAPIの出番です．

```js

  var slack_url = `https://slack.com/api/users.list?token=${process.env.SLACK_TOKEN}`;
  var display_name = new Array(names.length);
  var slack_id = [];
  function getSlackId(){ 
    return new Promise(function(resolve, reject){
      request.get(slack_url, (err, response, body) => {
        if (err) { reject('ERROR'); }
        var target = JSON.parse(body);
        async.eachOf(names, (name, index, callback) => {
          async.each(target.members, (slack, callback) => {
            if(name == slack.name){
              display_name[index] = slack.profile.display_name;
              slack_id[index] = slack.id;
            }
            callback();
          }, (err) => {
            if (err) { reject('ERROR'); }
            callback()
          })
        }, (err) => {
          if (err) { reject('ERROR'); }
          resolve(slack_id);
        });
      })
    })
  }
```
最初にリクエストbodyにあるslackのvertification tokenを確認します．
`request.body.text`にユーザIDが@付きで半角スペース区切りで送られてくるので分割しておきます．
`https://slack.com/api/users.list?token=TOKEN`はワークスペースに所属するユーザ全員の情報を取得するメソッドです．
ワークスペースの特定のユーザ情報を取得する場合は`https://slack.com/api/users.info?token=TOKEN`が使えます．しかしここで指定するキーはユーザーIDのみになるので，今回は`users.list`で全員分取得しました．．．


#### 対応表から勤怠登録名を取得する
今回は[こちら](https://www.npmjs.com/package/google-spreadsheet)のモジュールを使ってスプレッドシートを操作しました．
[本家のもの](https://developers.google.com/sheets/api/)を使いたかったのですが、シート内検索のクエリの書き方についてドキュメントが親切ではなかったので断念しました．

```js
var fangus_name = new Array(names.length);
function getFungusName(){
  return new Promise(function(resolve, reject){
    translate.useServiceAccountAuth(credentials, function(err){
      if (err) { reject('ERROR'); }
      translate.getInfo(function(err, info){
        const translate_sheet = info.worksheets[0];
        async.eachOf(slack_id, (name, index, callback) => {
          translate_sheet.getRows({
            query: 'slackid=' + name,
            limit: 1
          }, function(err, row){
            if (err) { reject('ERROR'); }
            if (row[0] === undefined){
              res.status(200).send('検索対象外の人物が含まれる模様');
            }
            fangus_name[index] = row[0].id;
            callback();
          })
        }, (err) => {
          if (err) { reject('ERROR'); }
          resolve(fangus_name);
        })
      })
    })
 })
}
```
やってることは当該のユーザIDのレコードを取得しているだけです．
スプレッドシートを操作するための認証はサービスアカウントを使いました．注意する点は，サービスアカウントでスプレッドシートのAPIを有効化しておくことと，サービスアカウントのアドレスをスプレッドシート側で共有しておく点です．またgoogle-spreadsheetsのインスタンスを作成するときにスプレッドシートのIDを渡す必要があるので，URLのd/と/editの間の文字列を控えてください．

#### スプレッドシートから時間を取得
ここで使うスプレッドシートには最後にオンラインだった時間を一分ごとに記録してます．
その時間と現在時刻を比較してオフィスにいるかどうか判断します．

```js
  function getLastOnline(){
    return new Promise(function(resolve, reject){
      spreadsheet.useServiceAccountAuth(credentials, function(err){
        if (err) { reject('ERROR'); }
        var res_data = '';
        spreadsheet.getInfo(function(err, info){
          if (err) { reject('ERROR'); }
          sheet = info.worksheets[0];
          async.eachOf(fangus_name, (name, index, callback) => {
            sheet.getRows({
              query: 'userid=' + name,
              limit: 1
            }, function(err, row){
              if (err) { reject('ERROR'); }
              if (row[0] === undefined) {
                // シートに名前がなければ200で返す
                res.status(200).send('検索対象外の人物が含まれる模様');
              }
              var targetDate = moment(row[0].lastonline);
              // lastonline時刻と現在時刻を取得
              targetDate.format('YY/MM/DD HH:mm:ss');
              var nowDate = moment()
              nowDate.add(9, 'hours').format('YY/MM/DD HH:mm:ss');
              // 時間差を計算
              if (targetDate.isSame(nowDate, 'day') && targetDate.isSame(nowDate, 'month') && nowDate.diff(targetDate, 'minutes') <= 2){
                res_data += display_name[index] + ' さんはいますよ! \n';
              } else {
                res_data += display_name[index] + ' さんはいません! \n';
              }
              callback();
            })
          }, function(err){
            if (err) { reject('ERROR'); }
            resolve(res_data);
          })
        })
      })
    })
  }
```
途中エラーが出るとその場所でエラーステータス500のレスポンスを返すので，Slack側で生のエラーメッセージが飛んできます．ここら辺の見栄えをよくするために，予想できるエラーに関してはエラーメッセージを吐く前に処理して正常ステータスでレスポンスを返すような工夫が必要でした．
ここら辺があまり上手くできなかったですね．．．
最後にPromiseで実行します．

```js
var google_spreadsheet = require('google-spreadsheet');
var spreadsheet = new google_spreadsheet(process.env.WORKSHEET_ID);
var translate = new google_spreadsheet(process.env.TRANSLATE_ID);
var credentials = require('PATH_TO_CREDENTIAL');  
const async = require('async');
const moment = require('moment');
const request = require('request');

var data;
var names = [];

exports.memberSearch = function searchAttendance(req, res){

  if (req.body.token != process.env.SLACK_VERTIFICATION_TOKEN){
    res.status(403).send('Permission Denied');
    return;
  }

  // textを分割
  data = req.body.text;
  names = data.split(' ');
  // 文頭に入ってる@を消す
  for (var key in names) {
    var tmp = names[key].split('@');
    tmp.shift();
    names[key] = tmp[0];
  }

// 全て実行
  getSlackId()
    .then(getFungusName)
    .then(getLastOnline)
    .then(function(result){
      res.status(200).send(result);
    })
    .catch(function(err){
      res.status(500).send(err);
    });
  }
```

## こんな感じ
![image.png](https://qiita-image-store.s3.amazonaws.com/0/263803/b0be1fb9-bb46-6dfd-cdd3-b04565a6667f.png)

コマンドの後にメンションをつければ検索できます．複数人でもOK．
本人にメンションが飛ぶこともありませんし，レスポンスは自分からしか見えないので任意のチャンネルで実行できます！

## 最後に
Cloud Functions便利ですね．nodeの処理はかなり非同期を意識して書かないと動かないので，自分の力不足を痛感しました．
今回の例では特に問題ないのですが，asyncが上手く動かない場合もあるそうなので，規模によっては色々考えなきゃいけないですね．