有給管理できるSlackAppをつくった話とSlackApp入門

この記事はSlack Advent Calendar 2017の18日目の記事です。

[Slack Advent Calendar 2017 - Qiita](https://qiita.com/advent-calendar/2017/slack)

# tl;dr 

SlackAppをつかえば、入力側のインターフェイスをSlackから提供することができ、気軽にslackチームメンバーにアプリを利用してもらえるので便利という話

## きっかけ

個人的にちょっとした有給管理をしていまして、同じプロジェクトメンバにも使ってみてほしいときに、
便利でしたので、Slackアプリの作り方チュートリアル風に紹介したいと思います。

## 題材

**説明用サンプル有給管理アプリ**

- 簡易有給管理アプリ
 - ユーザマスタ/有給取得情況のシンプルな2リソースで作成しました。
 - バックエンド側はexpress/mongo構成
 - フロント側はSlackにてCLIで行う
 - リソース操作は有給の登録、削除、参照のみ

構成図としては以下のイメージです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/73202/318b5b97-635a-c35a-4153-82fe7ecd7abd.png)

Slackの利用イメージは以下です。

- 利用メニュー表示
![image.png](https://qiita-image-store.s3.amazonaws.com/0/73202/78ba5cbd-bcce-2183-172e-62eefb3a429b.png)

- create/deleteの場合、日付選択
![image.png](https://qiita-image-store.s3.amazonaws.com/0/73202/c985651c-5dff-49d0-08aa-710fe2368c48.png)

- 有給登録完了！
![image.png](https://qiita-image-store.s3.amazonaws.com/0/73202/c878a72d-d6b6-42e4-d5f3-baf76f536d37.png)

- my statusの場合、有給参照
![image.png](https://qiita-image-store.s3.amazonaws.com/0/73202/434b2854-9af5-fd25-bfa0-dae247ba604a.png)

## express/mongoのベースファイル

最小限の構成で作成しています。express/mongoの細かい説明はここでは触れません。

```:index.js
import http from 'http'
import express from 'express'
import bodyParser from 'body-parser'
import normalizePort from 'normalize-port'
const port = normalizePort(process.env.PORT || '3000');
const app = express();
const router = express.Router();

import mongoose from 'mongoose';
mongoose.Promise = global.Promise;
mongoose.connect(process.env.MONGO_URL, {
  useMongoClient: true,
});

http.createServer(app).listen(port, () => {
  console.info(`server listening on port ${port}`);
});
```

**注意**
- expressをes6で記載しているので、立ち上げる際は``$ babel-node index.js``で起動してください。
- slackAppとリクエストのやりとりを行えるように、外部エンドポイントが必要なので、
ローカルで試す際には該当ポートでngrokをたちあげておいてください。 eg. ``ngrok http 3000``

## slackAppのSetup

[SlackApp](http://api.slack.com/apps)から作成できます。
順にコードサンプルをもとに説明していきます。

Slackのclientライブラリを使うので以下を加えておいてください。

```:package.json
    "@slack/client": "^3.14.0",
    "@slack/events-api": "^1.0.1",
    "@slack/interactive-messages": "^0.2.0",
```

## Bot user

ボットを作成します。ボットの表示名を適当に設定してください。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/73202/6eb06c0c-d416-85f3-36b2-0833314d166a.png)

ボットユーザのでのAPI操作にTokenが必要なのでInstallAppメニューでBotのOAuthTokenから取得しておいてください。
環境変数化して、[dotenv](https://www.npmjs.com/package/dotenv)で読み込むのがベターです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/73202/1b6a3d59-0a1c-bdae-5918-9bba8daa8233.png)

使い所は、ボットユーザにてメッセージを投稿する機能で、なんらかのアクションをした際に、正常/異常時の結果を通知するときに使うことが多いです。今回のケースでは、正しく有給登録できたとき、正しく登録できなかったときに、ボットくんが知らせてくれるような仕組みにしています。

Nodejsの[slackClient](https://www.npmjs.com/package/@slack/client)ライブラリを使って実装します。botクラスを作成し、メッセージ投稿できる関数を作成します。

```:lib/bot.js
import { WebClient } from '@slack/client'

class Bot {
  constructor(){
    this.slackClient = new WebClient(process.env.SLACK_BOT_TOKEN);
  }
  postMessage(channel, message, option={}) {
    this.slackClient.chat.postMessage(channel, message, {
    })
  }
}

const bot = new Bot
export default bot
```

このようなコードでうごかせます。
利用する際は、ファイルをimportして利用してください。

```:index.js
import bot from './lib/bot';

function sample() {
  bot.postMessage(process.env.TARGET_CHANNEL_NAME, 'hello');
}

```

## Slash Command

任意のアクションを呼び出したいときに利用します。Slackのreminder機能もslashコマンドのひとつです。
SlashCommandメニューをひらいて、リクエストURLを``ngrok_url + /slash/commands``にしてください。
リクエストURLは任意ですが、express側のrouterで定義するようにしてください。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/73202/bf504b17-230c-39b9-7609-a2eff1b641f2.png)

**kintai**コマンドを登録しました。
``/kintai``をハンドリングするバックエンド側のコードは以下です。

```:index.js
app.use('/slack/commands', (req, res) => {
  const { token, text, user_id: userId } = req.body;
  // token check
  if (token === process.env.SLACK_VERIFICATION_TOKEN) {
    console.log('Token valid');
    // 任意の処理をながす
    res.send('');
  } else {
    console.error();('Token invalid');
    res.sendStatus(500);
  }
});
```

- 不正なリクエストをふせぐため、**tokenチェック**は必ずおこなうようにしてください。
- Slashコマンドから後述のInteractiveMessage機能につなげます。

## Interactive Messages

インタラクティブにアクションを呼び出したいときに、ボットとチャットベースで利用できます。
InteractiveComponentsメニューをひらいて、リクエストURLを``ngrok_url + /slash/actions``にしてください。リクエストURLは任意ですが、設定したURLをexpress側のrouterで定義するようにしてください。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/73202/1c2c54b8-00de-3ea6-ecfd-40eceb019db4.png)

さきほどのslashコマンド後のcallback内に、有給管理リソースをインタラクティブに操作できるstartInteractiveMessage関数を定義します。

```:index.js
app.use('/slack/commands', (req, res) => {
  const { token, text, user_id: userId } = req.body;
  // token check
  if (token === process.env.SLACK_VERIFICATION_TOKEN) {
    console.log('Token valid');
    // 追加
    bot.startInteractiveMessage(userId)
    res.send('');
  } else {
    console.error();('Token invalid');
    res.sendStatus(500);
  }
});

リクエストURLをハンドリングできるように以下、追記します。

```:index.js
const slackMessages = createMessageAdapter(config.SLACK_VERIFICATION_TOKEN);
app.use('/slack/actions', slackMessages.expressMiddleware());
```

post内容にattachmentをつけて表示したいメニューを定義すれば対話ができるようになります。

- 有給登録(create)
- 有給削除(delete)
- 有給参照(index)

この三つのメニューをよびだし、次のアクションをcallback_idに記載してつなげていきます。

```:lib/bot.js
import { WebClient } from '@slack/client'

class Bot {
  ~
  async startInteractiveMessage(userId) {
    try {
      await this.slackClient.im.open(userId)
      this.slackClient.chat.postMessage(process.env.TARGET_CHANNEL_NAME,
        'I am kintaibot, and I\'m here to help bring you fresh holiday :grin: \n',
        { attachments: [
          {
            color: '#5A352D',
            title: 'when you take a holiday?',
            callback_id: 'order:start',
            actions: [
              {
                  name: 'create',
                  text: 'create',
                  type: 'button',
                  style: 'primary',
                  value: 'create'
              },
              {
                  name: 'delete',
                  text: 'delete',
                  type: 'button',
                  style: 'danger',
                  value: 'delete'
              },
              {
                  name: 'mystatus',
                  text: 'my status',
                  type: 'button',
                  style: 'default',
                  value: 'mystatus'
              },
            ],
          },
        ],
      })
    } catch (e) {
      console.error(e)
    }
  }
}

callback_idを処理するには、以下のように記載してください。
選んだメニューから処理分岐するコードサンプルです。

```:index.js
slackMessages.action('order:start', (payload, respond) => {
  const selectedAction = payload.actions[0].value

  switch (selectedAction){
    case 'create':
      getSelectList('order:select_type')
        .then(respond)
        .catch(console.error);
      const message = "choose the day when you get"
      return acknowledgeActionFromMessage(payload.original_message, 'order:start', message);
      break;
    case 'delete':
      // createとほぼ同じなので省略
      break;
    case 'mystatus':
      const showPaidHoliday = async () => {
        try {
          const res = await paidHoliday.schema.methods.show(payload.user.id);
          const dayList = dateUtil.decorateFormat(res);

          const totalHour = dateUtil.summaryDate(res)
          const totalDay = totalHour / 8

          const message = "```" + "\n" + dayList + "total:" + totalDay + "days\n" + "```"

          bot.postMessage(config.TARGET_CHANNEL_NAME, message);
      } catch(e) {
        console.log(e)
        const message = `申請一覧取得に失敗しました！`
        bot.postMessage(config.TARGET_CHANNEL_NAME, message)
      }
    };
    showPaidHoliday()
    break;
  }
});

```

createとdeleteが選ばれた時は日付のリストをさらに選択できるようにします

```:index
// Helper functions

function findAttachment(message, actionCallbackId) {
  return message.attachments.find(a => a.callback_id === actionCallbackId);
}

function acknowledgeActionFromMessage(originalMessage, actionCallbackId, ackText) {
  const message = cloneDeep(originalMessage);
  const attachment = findAttachment(message, actionCallbackId);
  delete attachment.actions;
  attachment.text = `:white_check_mark: ${ackText}`;
  return message;
}

async function getSelectList(callbackAction) {
  return {
    text: 'choose the day when you get a paid holiday',
    attachments: [
      {
        color: '#5A352D',
        callback_id: callbackAction,
        text: '',
        actions: [
          {
            name: 'select_type',
            type: 'select',
            options: dateUtil.getDayList(),
          },
        ],
      },
    ],
  };
}

```
以上です。

## Event subscriptions(おまけ)

[SlackEvent](https://api.slack.com/events)をSubscribeできる機能です。
今回は、以下のようなアクションをSubscribeしてバックエンド側のユーザ管理が容易に行います。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/73202/58b44ad1-fcd3-b28f-c00f-3f232ecef7b8.png)

- ユーザが特定のchannelに入ってきて、ユーザ登録APIを起動する (team_join)
- ユーザが特定のchannelからぬけた 、ユーザ登録APIを起動する(member_left_channel)

ngrokURL + `/slack/events`でエンドポイントを登録しておきます。

バックエンド側でも ``/slack/events``のrouteをハンドルできるように定義しておきます。

```:index.js
import { createSlackEventAdapter } from '@slack/events-api';

const slackEvents = createSlackEventAdapter(process.env.SLACK_VERIFICATION_TOKEN);

app.use('/slack/events', slackEvents.expressMiddleware());
```

``member_joined_channel``をハンドルするコードは以下です。

```:index.js
slackEvents.on('member_joined_channel', event => {
  if (event.channel == config.TARGET_CHANNEL_ID) {
    User.find_or_create_by(event.user);
  }
});
```

## さいごに

webAPIが公開されているようなシステムであれば基本的にはslackのCLIコマンドライクに操作できるようになります。goでええやんけ、と思うかもしれませんが、Slack内で使えるメリットが大きいのかなと思います。エンドポイントをGASに指定することももちろん可能で、SlackAppとGASを組み合わせると業務効率が進む箇所は非常に多いのではないでしょうか。

説明わかりにくい箇所あったかと思うので、[コード](https://github.com/hayashiki/slack-kintai)をあげておきますので参考にしていただければと思います。