TypeScript で作る Slack Bot - Slash Commands 編

TypeScript で Slack Bot を作ります。
まず最初に [Slash Commands](https://api.slack.com/slash-commands) を実行できるようにします。

## Slack から送られてくるリクエストを処理する

Slash との通信のやりとりは以下の形で行われます。

1. Slack クライアント(ブラウザ) で Slash Commands を実行する。
2. Slack サーバーにリクエストが送られる。
3. Slack サーバーは該当の Slash Commands を処理するサーバーにリクエストを中継する。
4. Slash Commands を処理するサーバーは送られてきたリクエストを処理して、 Slack サーバーにレスポンスを返す。
5. Slack サーバーは返されたレスポンスを Slack クライアントに中継する。

そのため、まずは Slack サーバーから送られてくるリクエストを処理するサーバーをローカルにたてます。

### ローカルのセットアップ

Node.js で一般的な [Express](http://expressjs.com/ja/) フレームワークを使います。
また、今回は JavaScript を生成せずに TypeScript を実行するために [ts-node](https://github.com/TypeStrong/ts-node) を使います。

```sh
$ yarn add express
$ yarn add --dev typescript ts-node @types/express
```

### リクエストを処理するコードを書く

ひとまず、リクエストを受け取ったら `pong!` とだけ返す処理を書いてみます。

```server.ts
import * as http from 'http';
import * as express from 'express';

const app = express();

app.post('/', (req, res, next) => {
  res.send('pong!');
});

http.createServer(app).listen(8080, () => {
  console.log('server listening on port 8080');
});
```

Slack からのリクエストは **POST** で送られてきます。とりあえずルートで受け付けます。

### サーバーを起動する

`yarn start` でサーバーが起動できるように、 **package.json** に追記します。

```package.json
{
  ...
  "scripts": {
    "start": "ts-node server.ts"
  }
}
```

`yarn start` を実行して以下のように表示されていればサーバーが起動できています。

```sh
$ yarn start
server listening on port 8080
```

ルートに POST リクエストを投げてみて `pong!` が返ってくるか確認します。

```sh
$ curl -X POST http://localhost:8080
pong! 
```

### ローカルサーバーを公開する

Slack からのリクエストを処理するサーバーをたてることができました。しかし、このままでは Slack サーバーからは呼び出せないので、呼び出せるように公開する必要があります。

今回は、 [ngrok](https://ngrok.com/) というサービスを使って公開します。

アカウント作成後はログイン後のトップページのガイダンスに従ってコマンドを実行します。

![ngrok.png](https://qiita-image-store.s3.amazonaws.com/0/95831/669d3042-0321-6367-eafe-e8ccd13e4a2f.png)

```sh
# 最初に一回だけ必要。ローカルの `ngrok.yml` にトークンを書き込む。 
$ ngrok authtoken <上記③に記載されているトークン>
# ngrok を実行。先ほどのサーバーはポート 8080 でリッスン。
$ ngrok http 8080
```

ngrok を実行すると下記のような内容が表示されます。

```
ngrok by @inconshreveable                                                                                (Ctrl+C to quit)

Session Status                online
Account                       <your name> (Plan: Free)
Version                       2.2.8
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://<id>.ngrok.io -> localhost:8080
Forwarding                    https://<id>.ngrok.io -> localhost:8080

Connections                   ttl     opn     rt1     rt5     p50     p90
                              102     0       0.00    0.00    0.19    0.97
```

`Forwarding` という所に記載された URL が公開されたサーバーの URL です。

この URL に先ほどと同様に POST を送って `pong!` が返ってくるか確認します。

```sh
$ curl -X POST https://<id>.ngrok.io
pong!
```

## Slash Commands を実行できる Slack App の設定をする

Slack からのリクエストを受け取る準備ができたので、 Slack で Slash Commands のリクエストを先ほど作成したサーバーに中継する設定を行います。

### Slack App の作成

Slack にログインした状態で、 [Slack API](https://api.slack.com/) を開き、中央にある **Start Building** というボタンをクリックします。

![slack-01.png](https://qiita-image-store.s3.amazonaws.com/0/95831/48c16c2b-81cd-9cc7-000f-d73f01dab23a.png)

ページ遷移後にダイアログが開きます。

![slack-02.png](https://qiita-image-store.s3.amazonaws.com/0/95831/e330a200-68c2-a5f8-cd40-5b70e24a037f.png)

1. **App Name** にアプリの名前をいれます。ここでは `my-first-app` としています。名前は後からでも変更可能です。
2. **Development Slack Workspace** で現在のワークスペースを選択します。
3. **Create App** をクリックします。

#### Slash Commands の作成 

`my-first-app` の管理画面に遷移した後、まずはじめに Slash Commands の設定を行います。

![slack-03.png](https://qiita-image-store.s3.amazonaws.com/0/95831/60abd5de-3a3f-1172-4042-bedab863b1a3.png)

1. 左側にあるメニューから **Slash Commands** をクリックします。
2. Slash Commands の管理画面が開きますが、何もないので **Create New Command** をクリックして、作成します。

コマンドの新規登録ダイアログが出るので、登録を行います。

![slack-04.png](https://qiita-image-store.s3.amazonaws.com/0/95831/3d8d3932-ad6e-ad47-f1e3-82a7731ae215.png)

1. **Command** : `/` ではじまるコマンド名を入力します。ここでは `/ping` とします。
2. **Request URL** : Slack サーバーがリクエストを送る先のサーバーの URL です。先ほど作った ngrok の URL を登録します。ルーティングはとりあえずルートで受け取るようにしているので `https://<id>.ngrok.io/` と入力します。
3. **Short Description** コマンドの説明文です。ここでは `Returns 'pong!'` とします。
4. **Save** ボタンを押して保存します。

**Usage Hint** および **Escape channels, users, and links sent to your app** についてはここでは省略します。
**Preview of Autocomplete Entry** では、どのように表示されるかのプレビューが見れます。

### アプリのインストール

次に、このアプリを実行できるようにインストールします。

![slack-05.png](https://qiita-image-store.s3.amazonaws.com/0/95831/7f0bbe63-66c1-b52f-8b91-fcc5c0b41d03.png)

アプリの管理画面に戻っているので、

1. メニューにある **Install App** をクリックします。
2. **Install App to Workspace** というボタンをクリックします。

承認画面が表示されます。

![slack-06.png](https://qiita-image-store.s3.amazonaws.com/0/95831/d2efa3ba-4ab2-521e-baf6-607610bf4360.png)

**Authorize** をクリックして承認します。

![slack-07.png](https://qiita-image-store.s3.amazonaws.com/0/95831/9a9575af-7aef-8d07-9f18-faf01d97b68e.png)

OAuth のトークンが表示されますが、現段階では利用しません。これは後からいつでも確認することができます。

## Slash Commands を実行する

これで Slash Commands の `/ping` が作成できたので、実行してみます。
適当なチャンネルで、 `/ping` と入力します。
以下のように `pong!` と返ってくれば成功です。

![slack-08.png](https://qiita-image-store.s3.amazonaws.com/0/95831/d7ff14f7-42e3-1398-3b5c-5c934c756cc6.png)


## Slash Commands の認証をする

ローカルでたてたサーバーを公開していますが、このサーバーへリクエストを送ってくるのは今回作成した Slack アプリだけとは限りません。
該当の Slack アプリからリクエストが送られてきた場合のみ処理をするようにする必要があります。
署名による認証が Slack では現在推奨されているので、以下の手順で署名の確認を行います。

### Signing Secret を確認する

まず最初に Slack アプリの署名に使う **Signing Secret** の値を確認します。
アプリの管理画面トップの二枚目の **App Credentials** というカードの中に各種認証情報が記載されていますが、その中に **Sigining Secret** という項目があるので、入力欄の右側にある **Show** というボタンを押して Signing Secret を表示させます。

![slack-09.png](https://qiita-image-store.s3.amazonaws.com/0/95831/b8de5c9b-8c95-88be-c14f-cfb9b64fd08e.png)

### 認証処理をコードに埋め込む

#### リクエストの raw body を取得する

認証処理の際にリクエストの raw body を利用します。 raw body を Express で扱えるようにする [raw-body](https://github.com/stream-utils/raw-body) というパッケージがすでに依存関係で入っているのでこれを使います。しかし、 raw body を取得する際に利用する content-type というパッケージの TypeScript の定義ファイルがないのでこれを追加します。

```sh
$ yarn add --dev @types/content-type
```

そして、 raw body を取得する処理をコードに追記します。

```server.ts
import * as http from 'http';
import * as express from 'express';
import * as contentType from 'content-type';
import * as getRawBody from 'raw-body';

const app = express();

/**
 * raw body を取得するための処理。
 * これで req['text'] に raw body の Buffer が入るようになる。
 */
app.use((req, res, next) => {
  getRawBody(req, {
    length: req.headers['content-length'],
    limit: '1mb',
    encoding: contentType.parse(req).parameters.charset,
  }, function (err, string) {
    if (err) {
      return next(err);
    }
    req['text'] = string;
    next();
  });
});

app.post('/', (req, res, next) => {
  res.send('pong!');
});

http.createServer(app).listen(8080, () => {
  console.log('server listening on port 8080');
});
```

#### リクエストのタイムスタンプを取得する

次に、リクエストのヘッダ `X-Slack-Request-Timestamp` からタイムスタンプをとります。[リプレイアタック](https://ja.wikipedia.org/wiki/%E5%8F%8D%E5%B0%84%E6%94%BB%E6%92%83)対策としてタイムスタンプに大きなずれがないかチェックします。

```server.ts
import * as http from 'http';
import * as express from 'express';
import * as contentType from 'content-type';
import * as getRawBody from 'raw-body';

const app = express();

/**
 * raw body を取得するための処理。
 * これで req['text'] に raw body の Buffer が入るようになる。
 */
app.use((req, res, next) => {
  getRawBody(req, {
    length: req.headers['content-length'],
    limit: '1mb',
    encoding: contentType.parse(req).parameters.charset,
  }, function (err, string) {
    if (err) {
      return next(err);
    }
    req['text'] = string;
    next();
  });
});

app.post('/', (req, res, next) => {
  // ヘッダからタイムスタンプを取得。キーはすべて小文字。
  const timestamp = req.headers['x-slack-request-timestamp'] as string;
  // タイムスタンプが5分以上ずれていたらエラーにする。
  if (Math.abs(parseInt(timestamp, 10) - Math.floor(new Date().getTime() / 1000)) > 60 * 5) {
    res.sendStatus(403);
    return;
  }
  res.send('pong!');
});

http.createServer(app).listen(8080, () => {
  console.log('server listening on port 8080');
});
```

#### 署名の確認をする

最後に署名の確認を行います。リクエストのヘッダ内に署名が含まれているので、これの値が正しいかの確認をします。

##### Signing Secret を環境変数から読み込めるようにする

署名の計算をする際に先ほど確認した **Signing Secret** の値を使います。この値はハードコーディングできないものなので、環境変数 (`process.env`) から取得するようにします。
今回は環境変数に値をセットするのに npm scripts の中で環境変数をセットする処理を記述する方法ではなく、 `.env` ファイルから値を取得する方法を使うため、 [dotenv](https://github.com/motdotla/dotenv) というパッケージを使います。

```sh
$ yarn add --dev dotenv
```

起動時に `.env` ファイルを読み込ませるために package.json を書き換えます。

```package.json
{
  ...
  "scripts": {
    "start": "ts-node -r dotenv/config server.ts"
  }
}
```

`.env` ファイルに **Signing Secret** の値とそれのキーを記述します。

```..env
SLACK_SIGINING_SECRET=<Signing Secret の値>
```

##### 署名の計算をし、照合する

署名は HMAC SHA256 という方式を利用し、以下のように計算します。

1. バージョン番号 `v0` にタイムスタンプ、そしてリクエストの raw body を　`:` 区切りで結合した文字列を作る。
2. **Signing Secret**をキーにして hmac を作成する。
3. 1. で作成した文字列を hmac に渡し、16進数でダイジェストする。
4. それの頭に `v0=` をつける。

そして、この値をリクエストのヘッダ `X-Slack-Signature` の値と一致するか確認します。

コードは下記のようになります。
署名の計算をするには `crypto` というパッケージが必要になりますが、すでに依存している状態なのでそのまま使えます。

```server.ts
import * as http from 'http';
import * as express from 'express';
import * as contentType from 'content-type';
import * as getRawBody from 'raw-body';
import * as crypto from 'crypto';

const app = express();

/**
 * raw body を取得するための処理。
 * これで req['text'] に raw body の Buffer が入るようになる。
 */
app.use((req, res, next) => {
  getRawBody(req, {
    length: req.headers['content-length'],
    limit: '1mb',
    encoding: contentType.parse(req).parameters.charset,
  }, function (err, string) {
    if (err) {
      return next(err);
    }
    req['text'] = string;
    next();
  });
});

app.post('/', (req, res, next) => {
  // ヘッダからタイムスタンプを取得。キーはすべて小文字。
  const timestamp = req.headers['x-slack-request-timestamp'] as string;
  // タイムスタンプが5分以上ずれていたらエラーにする。
  if (Math.abs(parseInt(timestamp, 10) - Math.floor(new Date().getTime() / 1000)) > 60 * 5) {
    res.sendStatus(403);
    return;
  }
  // ヘッダから署名を取得する。キーはすべて小文字。
  const actualSignature = req.headers['x-slack-signature'] as string;
  // 署名の元となる文字列を作成。
  const sigBaseString = `v0:${timestamp}:${req['text']}`;
  // Signing Secret をキーにして、 sha256 アルゴリズムを使用した hmac を作成。
  const hmac = crypto.createHmac('sha256', process.env.SLACK_SIGNING_SECRET);
  // 計算する。
  const digest = hmac.update(sigBaseString).digest('hex');
  // 頭に v0= を付けて完成。
  const expectedSignature = `v0=${digest}`;
  // 送られてきた署名と計算した署名を比較し、一致していなければエラーにする。
  if (actualSignature !== expectedSignature) {
    res.sendStatus(403);
    return;
  }
  res.send('pong!');
});

http.createServer(app).listen(8080, () => {
  console.log('server listening on port 8080');
});

```

これで Slack からのリクエスト以外は受け付けないようになりました。

## 次回は

コマンドに基づいてインタラクティブなやりとりができるようにします。

ここまでのコードは [GitHub](https://github.com/kobanyan/slackbot-sample-with-typescript/tree/master/slash-commands) にもアップしてあります。