Slash Commands と Slack Interactive Message 入門

## つくるもの
* Slash interactive message を使ったインタラクティブな Slack
    * 今回は slash command ですが、もちろん、 outgoing と組み合わせたり bot にしても大丈夫です

* [GolangでSlack Interactive Messageを使ったBotを書く](http://tech.mercari.com/entry/2017/05/23/095500) ではコードはかけると思うのですが、 Slack 上での設定が多すぎて迷うと思ったので書きました。
* 実装側は Deeeet さんので十分です


## やること
* Slack 上での登録/設定
* Global から Request できる Web サーバ (API)

### 補足
* 本番環境は適宜設定するとして、開発環境は [ngrok](https://ngrok.com/) を使うとよいです（が自社のセキュリティルールに合わせてください)
* mac の場合は `brew cask install ngrok` で入ります

## Slack 上での登録/設定
事前に `ngrok http 3000` などを実行し、エンドポイントを取得しておいてください

1. https://api.slack.com/apps を開く
1. 「Create New App」で App を作成
![image.png](https://qiita-image-store.s3.amazonaws.com/0/3379/02a0c0da-6514-4a5c-9f2a-1dc2cdedbcd8.png)
    1. Slack Team を選ぶ
    1. あとで配ったりもできるので、手元のテスト環境用の team で大丈夫です
1. 「Basic information」->「Add features and functionality」から「Slash Commands」を選択し、下記を設定
    1. Command: Slack 上で 「/XXX」 と入力して app を呼び出すコマンドを決めます
    1. Request URL: app の呼び出しエンドポイントを登録します
    1. その他は適当に
1. 「Interactive Messages」を設定
    1. Request URL: Button / Menu を選択したときに呼び出されるエンドポイントを登録します

## API サーバを書く

* 今回は適当に node で書いていますが、好きな言語で書いてください。

### Slash Command を受けて Button / Menu を返す
* slash コマンドからの API 呼び出しは `POST` で来ます

```json
{
  "token":"XXXXXXXXXXXXXXXXX",
  "team_id":"XXXXXXXXXXXX",
  "team_domain":"muddydixon",
  "channel_id":"XXXXXXXXXXX",
  "channel_name":"general",
  "user_id":"XXXXXXXXXXXX",
  "user_name":"muddydixon",
  "command":"/deploy",
  "text":"",
  "response_url":"https://hooks.slack.com/commands/XXXXXXXXX/XXXXXXX/XXXXXXXXXXX
}
```
* command を確認して適切な処理に振り分ければ1アプリで複数のコマンドを受けることができます
* Interactive Message は現時点で Button と Menu を使うことができます。
    * [Interactive Messages](https://api.slack.com/interactive-messages) にサンプルの JSON も載っていますので参考にしてください
* Button と Menu の場合で返却する JSON が異なるので、適当に `messageBuilder` を作って簡略化しておきました
* Slack の該当 channel で設定した slack command をうち、うまくいくと下記のように応答が返ってきます

![image.png](https://qiita-image-store.s3.amazonaws.com/0/3379/d43728b9-34d3-407d-1f69-301348e069e3.png)

### Interactive Message のユーザ応答を受ける
* Interactive Message からの API 呼び出しも `POST` できます
* body.payload の中身は文字列化された JSON で parse が必要です (下記には parse した JSON を記載しておきます)
 
```json
{
  "actions":[{"name": "version", "type": "button", "value": "0.0.2"}],
  "callback_id": "version",
  "team": {"id": "XXXXXXX", "domain": "muddydixon"},
  "channel": {"id": "XXXXXXXXX", "name": "general"},
  "user": {"id": "XXXXXXXXX", "name": "muddydixon"},
  "action_ts": "1499824071.755716",
  "message_ts": "1499824068.033282",
  "attachment_id": "1",
  "token": "XXXXXXXXXXXXXXXXX",
  "is_app_unfurl": false,
  "original_message": {
    "text": "デプロイするバージョン",
    "bot_id": "XXXXXXXXX",
    "attachments": [
      {
        "callback_id": "version",
        "fallback": "choose version",
        "id": 1,
        "actions": [
          {
            "id": "1", "name": "version", "text": "0.0.1",
            "type": "button", "value": "0.0.1", "style": ""
          }, {
            // 略
          }
        ]
      }
    ],
    "type": "message",
    "subtype": "bot_message",
    "ts": "1499824068.033282"
  },
  "response_url": "https: //hooks.slack.com/actions/XXXXXXXXXXXX/XXXXXXXXXXXXXXX/XXXXXXXXXXXXXXX"
}
```
* レスポンスのルールとしてはきちんと `{text: "XXX"}`を返して、履歴を残すようにしましょう

### サンプルコード

```js
const http = require("http");
const express = require("express");
const BodyParser = require("body-parser");

const app = express();
app.use(BodyParser.json());
app.use(BodyParser.urlencoded({extended: true}));

// body を確認するため
app.use((req, res, next)=>{
  console.log(`${req.path}, ${req.method}, ${JSON.stringify(req.query)}, ${JSON.stringify(req.body)}`);
  next();
});

// 返却する json を組み上げるのが面倒なので builder
const messageBuilder = (text = "", subtext = "", type = "button", name = "choice", actions = [], options = {})=>{
  if(["button", "select"].indexOf(type) === -1) return null;
  return {
    text,
    response_type: options.responseType || "in_channel",
    attachments: [
      {
        text: subtext,
        fallback: options.fallback || `choose ${name}`,
        callback_id: options.callback_id || `${name}`,
        color: options.color || "info",
        attachment_type: "default",
        actions: type === "button" ? actions.map((action)=>{
          return {
            name,
            text: action,
            type,
            value: action.toLowerCase()
          };
        }) : [{
          name,
          type,
          text: `pick a ${name}`,
          options: actions.map((action)=>{
            return {
              text: action,
              value: action
            };
          })
        }]
      }
    ]
  };
};

// commands
const commands = {
  "/deploy": ()=>{
    // ここでなんかの処理を書く
    // ansible 叩くとか
    
    return messageBuilder(
      "デプロイするバージョン", "", "button", "version",
      ["0.0.1", "0.0.2", "0.0.3"]);
  }
};

Object.keys(commands).forEach((command)=>{
  app.post(`${command}`, (req, res, next)=>{
    if(!req.body) return null;
    if(req.body.command){
      return res.json(commands[req.body.command]());
    }else if(req.body.payload){
      const payload = JSON.parse(req.body.payload);
      const action = payload.actions[0];
      const type = action.type;
      return res.json({
        text: `you choose ${type === "button" ? action.value : action.selected_options[0].value}`
      });
    }
    return res.json({text: "invalid message"});
  });
  app.post(`${command}/options`, (req, res, next)=>{
    res.json({});
  });
});


const server = http.createServer(app);
const port = process.env.PORT || 3000;
server.listen(port);
server.on("listening", ()=>{
  console.log(`listening on ${port}`);
});
server.on("error", (err)=>{
  console.error(err);
});
```