【Slack】OOOoO、キミにきめた！Bolt on Docker + Cloud Runでユーザーをランダムで選んでくれるSlash commandを作った話

こんにちは。
チーム内で週一で勉強会をやっているのですが、気付いたら3回に2回は僕がやってます。
「こりゃいかんぞ！Outputこそ最大の学びの場やぞ！」と思ったのですが、なんか自分から次はあなたねと指名するのもプレッシャー与えちゃうかも...と悩んでいました。

<b>その仕事、Slackで。</b>

ということで、Slackのslash commandに次の発表者を決めてもらうことにしました。
Slackには[Bolt](https://slack.dev/bolt/ja-jp/concepts)というnode.jsで動作するSlack app frameworkがあるので、今回はそれをDocker上で動作させ、最終的にはGCPのサーバーレスコンテナランタイム[Cloud Run](https://cloud.google.com/run/?hl=ja)でslash commandを提供するところまでやってみます。

アウトプットとしては、Slackのチャンネルで`/kiminikimeta`とコマンドを打つと、Botが「`@xxxxx、キミに決めた`」とそのチャンネルのユーザーから1人をランダムで選出してくれる感じをイメージしてます。

# やること
1. Slack appの作成
2. Bolt on Dockerの実装
1. ngrokでローカル環境を公開
3. slash commandの作成
4. ローカル環境で動作確認
5. GCRへDocker imageを登録
6. Cloud Runにサービスを登録
7. slash commandのリクエストURLをCloud Runに向き先変更
7. 本番環境で動作確認

並べてみると手順多いですね。一個一個はそんななのではりきっていきましょう！

# Slack appの作成
まずはSlack appを作成します。GUIで。
Slack appはSlack apiの[Your Apps](https://api.slack.com/apps)のページから「Create New App」をクリックすれば作成することができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/7b6e1d73-8508-5fd1-9204-31796a4fd82b.png)

`App Name`と`Development Slack Workspace`の入力・選択が求められるので教えてあげます。`App Name`は今回は『test』にしてますが、本番利用時は『Pokemon Master』にする予定です。

これでひとまずSlack appの作成は完了です。以下のようにSlack appの詳細ページに遷移していると思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/93a5afb5-2f8c-e225-7244-0c4759cf258c.png)

次にSlack appに情報へのアクセス権限を与えてあげます。
左のサイドメニューから『OAuth & Permissions』を選択します。ここでSlack botのSlackのワークスペースやチャンネルの情報へのアクセス権限を付与することができます。以下の権限を追加してあげます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/19a813ef-5020-77b0-1822-d339e349c73e.png)
付与した権限は以下の通り。

- commands -> slash commandを使える権限
- chat:write -> slackにコメントを投稿できる権限
- channels:read -> パブリックチャンネルの情報の閲覧権限。あとでslash commandが入力されたパブリックチャンネルのユーザー一覧を取得するために必要。
- groups:read -> プライベートチャンネルの情報の閲覧権限。あとでslash commandが入力されたプライベートチャンネルのユーザー一覧を取得するために必要。
- im:read -> ダイレクトメッセージの情報の閲覧権限。あとでslash commandが入力されたダイレクトメッセージのユーザー一覧を取得するために必要。
- mpim:read -> グループダイレクトメッセージの情報の閲覧権限。あとでslash commandが入力されたグループダイレクトメッセージのユーザー一覧を取得するために必要。

これが完了したら、同じページの上の方に「Install App to Workspace」ボタンがあるのでクリックします。（クリック後に認可画面が出ますが「Allow」です。

これでSlack appの作成とWorkspaceへの登録が完了しました。

# Bolt on Dockerの実装
次にBoltでSlack appの本体（slash commandを受け取った後の処理）を作っていきます。
まず、Boltの環境を整えたいのですが、Boltはnode.jsで動作するのでそれ用のDockerfileを用意します。

```dockerfile:Dockerfile
FROM node:13.7.0-stretch
ENV HOME="/app"
WORKDIR $HOME
COPY . $HOME
EXPOSE 3000
CMD ["node", "app.js"]
```
Dockerfileの詳細は省きますが、最終的に`app.js`を起動させるようにしてます。

また環境変数とかをいちいちオプション指定するのも面倒なのでDocker Composeも使っていきます。

```yml:docker-compose.yml
version: '3'

services:
  app:
    build: .
    environment:
      - SLACK_SIGNING_SECRET=XXXXXXXXXX
      - SLACK_BOT_TOKEN=XXXXXXXXXX
    volumes:
      - .:/app
    ports:
      - 3000:3000
```
ここで`SLACK_SIGNING_SECRET`と`SLACK_BOT_TOKEN`の二つの環境変数を指定しています。
これらは先ほどSlack apiページで作成したSlack appの値で以下のところで確認することができます。

- `SLACK_SIGNING_SECRET`: 「Basic Information」の「App Credentials」の「Signing Secret」
- `SLACK_BOT_TOKEN`: 「OAuth & Permissions」の「OAuth Tokens & Redirect URLs」の「Token for Your Workspace」の「Bot User OAuth Access Token」

上の`docker-compose.yml`の`XXXXXXXXXX`の部分はそれぞれの値に置き換えてください。

また、docker imageの中に`Dockerfile`と`docker-compose.yml`は不要なので含まないように`.dockerignore`を記述しておきます。

```text:.dockerignore
.dockerignore
Dockerfile
docker-compose.yml
```

ここで一度docker imageをbuildしておきます。

```
$ docker-compose build
```

次に`npm`でBoltをインストールします。

```
$ docker-compose run app npm init
$ docker-compose run app npm install @slack/bolt
```
`npm init`時にアプリ情報を聞かれますので、いい感じに答えてあげてください。

これでBoltのインストールが完了したので、実際にアプリを書いていきます。

```js:app.js
const { App } = require('@slack/bolt');

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET
});

app.command('/kiminikimeta', async ({ command, ack, say, context }) => {
  ack();
  try {
    // slash commandが叩かれたChannelに所属する全ユーザー情報を取得する
    const result = await app.client.conversations.members({
      token: context.botToken,
      channel: command.channel_id
    });
    const members = result.members;

    // 取得した全ユーザーから今回作成しているSlack appのBotを除外する
    const bot_idx = members.indexOf(context.botUserId);
    if (bot_idx > -1) {
      members.splice(bot_idx, 1);
    }

    // Bot以外のユーザーから1つをランダムで選び、Channelに投稿する
    const member = members[Math.floor(Math.random() * members.length)];
    say(`<@${member}>、キミに決めた！`);
  } catch (error) {
    console.log(error);
  }
});

(async () => {
  await app.start(process.env.PORT || 3000);
  console.log('⚡️ Bolt app is running!');
})();
```
`app.command()`以外の部分はBoltの型みたいなものなので無心で。`app.command()`のところは少し補足をしていきます。

まず、`/kiminikimeta`のslash commandを受け取れるように`app.command`の引数に指定してます。

最初に`ack()`を呼び出してます。これはお作法みたいです。Boltアプリがcommandを受け取ったことをSlack appに教えてあげています。これをしないとタイムアウトとかになってしまうのでお忘れなく。

この後はtry~catchでslack web apiを使っています。
[`conversations.members`](https://api.slack.com/methods/conversations.members) APIでcommandが送信されたチャンネルのユーザーを取得します。

```js:app.js（抜粋）
// slash commandが叩かれたChannelに所属する全ユーザー情報を取得する
const result = await app.client.conversations.members({
  token: context.botToken,
  channel: command.channel_id
});
const members = result.members;
```
`conversations.members`を利用するには`token`と`channel`のパラメータが必須項目です。
`token`は`Bot User OAuth Access Token`のことですが、これは`context.botToken`で取得することができます。
`channel`はslash commandが入力されたチャンネルを設定したいのですが、これは`command.channel_id`で取得することができます。
最後にレスポンスの内容からメンバーのIDの配列を`members`に格納しています。

```js:app.js（抜粋）
// 取得した全ユーザーから今回作成しているSlack appのBotを除外する
const bot_idx = members.indexOf(context.botUserId);
if (bot_idx > -1) {
  members.splice(bot_idx, 1);
}
```
次に先ほど取得したslash commandが入力されたチャンネルのユーザー一覧から今回のBot用のユーザーを除外します。BotのIDは`context.botUserId`で取得ができまして、上のコードで先ほどの`members`からBotのIDと一致する文字列を除去しています。

```js:app.js（抜粋）
// Bot以外のユーザーから1つをランダムで選び、Channelに投稿する
const member = members[Math.floor(Math.random() * members.length)];
say(`<@${member}>、キミに決めた！`);
```
Botを除外したなかからランダムで1つのUser IDを取得して`member`変数に代入しています。
最後に`say`メソッドを使ってslash commandの結果をチャンネルにポストします。
ここで`<@${member}>`とすることで、選ばれたユーザーにメンションをつけることができます。

ここまでがslash commandを入力されたチャンネルでランダムに1ユーザーが選ばれる処理の内容になります。結構シンプルですね。

# ngrokでローカル環境を公開
ここまでで大体コーディングは完了です。実際に期待通りに動くか、まずはローカル環境で確認してみましょう。
といったものの、Slack appからローカルのパソコンにアクセス手段は基本的にありません。
そこでSlackの公式でもおすすめされている方法として、[ngrok](https://ngrok.com/)を使ってローカルで動くアプリにインターネット経由でアクセスできるようにトンネルしてあげます。

Slackでも使い方が[こちらの記事](https://api.slack.com/tutorials/tunneling-with-ngrok)で紹介されています。
簡単にまとめると、

1. ngrokの[ダウンロードページ](https://ngrok.com/download)で自分のクライアントにあったファイルをダウンロードする
2. ダウンロードしたファイルをunzipする。
Downloadsディレクトリにダウンロードされた場合は、`$ unzip ~/Downloads/ngrok.zip`
3. `ngrok`が解凍されるので、$PATHにファイルを移動。
通常`/usr/local/bin`とかだと思うのでその場合は、`$ mv ~/Downloads/ngrok /usr/local/bin`
4. コマンドが使えるようになるためにターミナル再起動かshellの再読み込み。bashを使っている場合は多分`$ source ~/.bashrc`

をやります。こうすると`ngrok`コマンドを実行できるようになります。
今、DockerコンテナはPort 3000でやりとりができるようにしているので、以下のコマンドを実行してインターネットからDockerコンテナにアクセスできるようにします。

```
$ ngrok http 3000

Session Status                online
Session Expires               7 hours, 59 minutes
Version                       2.3.35
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://xxxxxxxx.ngrok.io -> http://localhost:3000
Forwarding                    https://xxxxxxxx.ngrok.io -> http://localhost:3000
```
なんだか色々出てきますが、これだけで`http://xxxxxxxx.ngrok.io`または`https://xxxxxxxx.ngrok.io`でアクセスすると`http://localhost:3000`または`https://localhost:3000`にフォワーディングされるようになっています。

おっと、Dockerコンテナをまだ立ち上げていませんでしたね。

```
$ docker-compose up
```
はい。これでローカル側のテスト準備は完了しました。

# slash commandの作成
次にSlack appの方でslash commandを作成していきます。slash commandを作成するときにコマンドを投げる先のURL（Bolt appのURL）が必要になるので先にngrokの設定をしていたのです。

slash commandはSlack appの管理画面の「Slash commands」メニューから「Create New Command」を選択することで作成できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/dd36839b-edfd-c2a7-1df6-66f12b705bee.png)

「Create New Command」のモーダルが表示されますので適宜情報を入力しますが、

- Command: `/kiminikimeta`　（Bolt側で指定したslash commandと同じであればOK）
- Request URL: https://xxxxxxxx.ngrok.io/slack/events
- Short Description: 適当に何か
- Usage Hint: 入れたければ適当に何か

といった感じに入力してください。注意が必要な点としてはRequest URLは先ほど`ngrok`で作られたURLの後ろに`/slack/events`のパスを追加してください。Boltがこのパスでリクエストを受け取るようになっているからです。

入力がおわったら「Save」してslash commandの作成完了です。

# ローカル環境で動作確認
ここまで行けばテスト準備万端です。
Slack appをインストールしたSlack Workspaceに行ってみましょう。
そして何か適当なテスト用チャンネルなどに今作ったSlack appを「Add App」して呟いてみましょう。

<b>`/kiminikimeta`</b>
![Group 5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/5a294e8c-f179-f0ce-e9d9-f81b1e725cee.png)
<b>うわぁ！俺かぁ！</b>

ということでこれでSlackからSlack appがslash commandを受け取り、ローカルで起動させているBolt on Dockerの処理結果をSlackにポストできていることが確認できました！

あとはこのコンテナをどこかにあげればいいだけですね。
コンテナと`ngrok`は`Ctrl+C`で一度落としておきましょうか。


# GCRへDocker imageを登録
今回は本番環境のアーキテクチャとしてGCPのCloud Runを選択しました。
コンテナをサーバーレスで動かしてくれるマネージドサービスです。ちょちょちょっと設定すれば、アクセスがあったときだけコンテナをデプロイして動いてくれるやつです。
選んだ理由はイケている気がしたからです！（こういうの大事だと思っている）

まず、GCPのコンテナレジストリである[GCR（Google Container Registry）](https://cloud.google.com/container-registry/docs?hl=ja)に今回作ったコンテナイメージをアップロードしていきたいと思います。

GCP及び`gcloud`コマンドはすでに使える状態という前提でお話します。 => `gcloud`コマンドがまだのかたは[こちら](https://cloud.google.com/sdk/docs/quickstart-macos?hl=ja)。

GCRではコンテナイメージは`[HOSTNAME]/[PROJECT-ID]/[IMAGE]`の形式でpushすることができます。
`HOSTNAME`はリージョンによって異なりますが、アジアの場合は「asia.gcr.io」を使うことができます。
`PROJECT-ID`は各々のGCP PROJECTの名前で、`IMAGE`は好きにつけてOKです。今回は`IMAGE`は`kiminikimeta`にしましょう。`PROJECT-ID`は仮にで「sample」とします。
つまりDocker imageのイメージ名は`asia.gcr.io/sample/kiminikimeta`になるということです。

Docker composeでビルドした際にこの名前のイメージができるように、`docker-compose.yml`を更新します。

```yml:docker-compose.yml
version: '3'

services:
  app:
    build: .
    image: asia.gcr.io/sample/kiminikimeta #追加
    environment:
      - SLACK_SIGNING_SECRET=xxxxxxxxxx
      - SLACK_BOT_TOKEN=xxxxxxxxxx
    volumes:
      - .:/app
    ports:
      - 3000:3000
```

更新が完了したらbuildします。

```
$ docker-compose build

Successfully tagged asia.gcr.io/sample/kiminikimeta:latest
```
ちゃんと指定したイメージ名でDocker imageが作成されましたね。
後はこれをGCRにpushします。

```
$ docker push asia.gcr.io/sample/kiminikimeta:latest
```

# Cloud Runにサービスを登録
Docker imageのpushがおわったらCloud Runの設定をしていきます。これはGUIでやってみます。
メニューから「Cloud Run」を選択しコンソールに移動したら「サービスを追加」を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/b62788ff-c3ab-2e06-73d8-e98aeb547204.png)

ここで各種設定をしていくのですが、以下のように設定しました。

- コンテナイメージのURL: 「選択」から先ほどpushしたイメージを選択する
- 「Cloud Run」 or 「Cloud Run for Anthos」: 「Cloud Run」
- Cloud Runのリージョン: Tokyo
- サービス名: kiminikimeta
- 認証: 未認証の呼び出しを許可
- コンテナポート: 3000
- 環境変数:
    - SLACK_SIGNING_SECRET: xxxxxxxxxx
    - SLACK_BOT_TOKEN: xxxxxxxxxx

「コンテナポート」と「環境変数」は「オプションのリビジョン設定を表示」をクリックしないと出てこないので見つけにくいかもしれないですね。その他の値はデフォルトのままでOKです。
ここまでできたら「作成」を選択してCloud Runサービスを作成します。

サービスの作成が完了すると以下のような画面になっていると思います。
![Group 6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/8322c1bb-2d23-9e0e-4ffb-a01467a84031.png)
右上の「URL」という部分がこのCloud Runサービスが動くトリガーとなるエンドポイントです。

# slash commandのリクエストURLをCloud Runに向き先変更
Slack appのslash commandはまだ`ngrok`のURLにリクエストを飛ばすように設定されているので、最後にエンドポイントをCloud RunのURLに更新します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/c2d87158-ba12-5c1d-9686-5f7393b11f68.png)
Slack appのslash commandsメニューの画面をみると、こんな感じに今登録されているslash commandを確認することができます。この鉛筆マークを押すと、そのslash commandの詳細を変更できるので、ここからURLを先ほどのCloud Runで作成したサービスのURLに更新しましょう。
このとき、`/slack/events`をパスに追加することをお忘れなきよう。
（Cloud RunのサービスのURLが`https://xxxxxxxxxxx.run.app`の場合、`https://xxxxxxxxxx.run.app/slack/events`と更新する。）

# 本番環境で動作確認
さて、ここまでで全ての作業がおわりました。最後にもう一度、Slackで`/kiminikimeta`を試してみましょう。

<b>`/kiminikimeta`</b>
<b>`/kiminikimeta`</b>
![Group 7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/0e488b92-ea5f-028b-753a-fec489b93504.png)

ちゃんとランダムにユーザーが選ばれていますね！！

# おわりに
今回はBolt on Docker + Cloud RunでSlash commandを作ってみました。
Dockerは元々使っていたのであれですが、Bolt & Cloud Runは初めましてだったにもかかわらず意外とスムーズにできた印象です。
Boltはドキュメントがそれなりにまとまっているし、console.logでデバッグしながらやればいろいろなことができそうですね。
Cloud Runはかなり直感的に使うことができるとわかりました。お値段次第ですが、これはけっこうおすすめかもしれない。

# 今回のOutput
今回のBolt on DockerのアプリはGithubにあげてます。気になる方は参考にしてください！

- [at946/kiminikimeta: Slackチャンネルからランダムにユーザーを選出してくれるSlash commandアプリ](https://github.com/at946/kiminikimeta)

# Reference
- [Slack | Bolt](https://slack.dev/bolt/concepts)
- [conversations.members method | Slack](https://api.slack.com/methods/conversations.members)
- [Container Registry のドキュメント  |  Google Cloud](https://cloud.google.com/container-registry/docs?hl=ja)
- [Cloud Run のドキュメント  |  Google Cloud](https://cloud.google.com/run/docs/?hl=ja)
- [node - Docker Hub](https://hub.docker.com/_/node/)
- [Using ngrok to develop locally for Slack | Slack](https://api.slack.com/tutorials/tunneling-with-ngrok)
- [ngrok - secure introspectable tunnels to localhost](https://ngrok.com/)
- [Docker Composeでビルド時に任意のイメージ名を指定する方法 - 水底](https://amaya382.hatenablog.jp/entry/2017/04/03/034002)