Elixir / Phoenix で Slack の Slash Command を作ってみたら快適だった

この記事は [ミクシィグループ Advent Calendar 2019](https://qiita.com/advent-calendar/2019/mixi) の7日目の記事です。

初めましての方は初めまして、19新卒エンジニアの techno-tanoC です。

最近、趣味で Slack の slash command を作っているので、 elixir / phoenix による入力された文字列をそのまま返す slash command (とオマケの隠し機能)を作るチュートリアルを書いてみます。

## Web app を作る
早速 Elixir / Phoenix のプロジェクトを作っていきましょう。

Slack の slash command は json を受け取って json を返すことを基本としているため、 HTML や Webpack は必要ありません。 `--no-html`, `--no-webpack` を付けます。また、今回は DB を扱わないため、 `--no-ecto` オプションも付けます。これらのオプションは名前の通り、 HTML, Webpack, Ecto をプロジェクトに含めないオプションです。

`mix phx` のバージョンは `1.4.11` を使いました。

```shell
$ mix phx.new echo --no-html --no-webpack --no-ecto
* creating echo/config/config.exs
* creating echo/config/dev.exs
* creating echo/config/prod.exs
* creating echo/config/prod.secret.exs
* creating echo/config/test.exs
* creating echo/lib/echo/application.ex
* creating echo/lib/echo.ex
* creating echo/lib/echo_web/channels/user_socket.ex
* creating echo/lib/echo_web/views/error_helpers.ex
* creating echo/lib/echo_web/views/error_view.ex
* creating echo/lib/echo_web/endpoint.ex
* creating echo/lib/echo_web/router.ex
* creating echo/lib/echo_web.ex
* creating echo/mix.exs
* creating echo/README.md
* creating echo/.formatter.exs
* creating echo/.gitignore
* creating echo/test/support/channel_case.ex
* creating echo/test/support/conn_case.ex
* creating echo/test/test_helper.exs
* creating echo/test/echo_web/views/error_view_test.exs
* creating echo/lib/echo_web/gettext.ex
* creating echo/priv/gettext/en/LC_MESSAGES/errors.po
* creating echo/priv/gettext/errors.pot

Fetch and install dependencies? [Yn] y
* running mix deps.get
* running mix deps.compile

We are almost there! The following steps are missing:

    $ cd echo

Start your Phoenix app with:

    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server
```

### Route を追加

`/` を slash command のパスとします。

```lib/echo_web/router.ex
 defmodule EchoWeb.Router do
   use EchoWeb, :router

   pipeline :api do
     plug :accepts, ["json"]
   end

-  scope "/api", EchoWeb do
+  scope "/", EchoWeb do
     pipe_through :api
+    post "/", SlackController, :index
   end
 end
```

### Controller を追加

slash command では入力したコマンドのテキスト部分( `/echo hello` なら `hello` の部分) がそのまま POST されるため、それを取り出して送り返せば良いことになります。

```lib/echo_web/controllers/slack_controller.ex
defmodule EchoWeb.SlackController do
  use EchoWeb, :controller
  def index(conn, %{"text" => text}) do
    render(conn, "index.json", %{text: text})
  end
end
```

### View を追加

View で Slack へ返却する json を組み立てます。[Block Kit Builder](https://api.slack.com/tools/block-kit-builder) を参考にしながら、どんなレスポンスを返すのか考えると良いでしょう。

```lib/echo_web/views/slack_view.ex
defmodule EchoWeb.SlackView do
  use EchoWeb, :view

  def render("index.json", %{text: text}) do
    %{
      blocks: [
        %{
          type: "section",
          text: %{
            type: "plain_text",
            text: text,
            emoji: true
          }
        }
      ]
    }
  end
end
```

## Slash Command を作成・インストールする

### slash command の作成・追加
[Slack API: Applications | Slack](https://api.slack.com/apps) の「Create New App」で新しい Slack App を作ります。

![Screenshot from 2019-12-07 15-39-28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49469/eb03e098-d71f-72bd-11ea-5e9b9685d16c.png)

「Development Slack Workspace」にはこの Slack App を管理する Slack Workspace を指定しましょう。

続いて Add features and functionality から slash commands を選択、 Create New Command を選択します。

![Screenshot from 2019-12-07 15-41-32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49469/29f14c12-30ec-735b-3ce2-1a6bddbc5832.png)

![Screenshot from 2019-12-07 15-43-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49469/029a4722-7e7d-ebe4-89f2-1d6507028280.png)

「Request URL」には先程作った Web app が動いているホスト + Port を指定しましょう。

Phoenix のデフォルトのポート番号は 4000 のため特に設定していなければ、ホスト名が `myhost` なら `http://myhost:4000/` です。 

あとはこの slach command を自分のワークスペースにインストールします。

![Screenshot from 2019-12-07 15-52-59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49469/1d309bda-cc6e-6105-18e7-acae43069211.png)

### 試す
インストールした slash command をテストします。 slash command のインストールができていれば `/echo` と打てば候補に出ると思います。 Phoenix サーバを起動しておき、メッセージを入力してみましょう。

```shell
$ mix phx.server
[info] Running EchoWeb.Endpoint with cowboy 2.7.0 at 0.0.0.0:4000 (http)
[info] Access EchoWeb.Endpoint at http://localhost:4000
```

![Screenshot from 2019-12-07 15-57-17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49469/ed1a09f9-930f-54cc-6d5d-67ea1719e765.png)

![Screenshot from 2019-12-07 15-58-32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49469/e0ba42dc-093d-505d-773b-27aa77983843.png)

## トークンの検証をする

このままでは slack ではない第三者からも echo コマンドを実行できてしまいます。これを防ぐために slash command のリクエストに含まれているトークンを検証することで正しいリクエストかどうかをチェックしましょう。 `Plug` という機能を使って実装します。

```lib/echo_web/plug/verify_token.ex
defmodule EchoWeb.Plug.VerifyToken do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    token = Application.get_env(:echo, :slash_command_token)

    unless conn.params["token"] == token do
      conn
      |> send_resp(403, "Invalid token")
      |> halt()
    end

    conn
  end
end
```

設定されたトークンとリクエストに入っているトークンが同じかどうかをチェックし、もし違えば `403` を返します。

あとは Router と Config に追記すれば OK です。

```lib/echo_web/router.ex.diff
 defmodule EchoWeb.Router do
   use EchoWeb, :router

   pipeline :api do
     plug :accepts, ["json"]
+    plug EchoWeb.Plug.VerifyToken
   end

   scope "/", EchoWeb do
     pipe_through :api
     post "/", SlackController, :index
   end
 end
```

```config/config.exs.diff
+ slash_command_token = System.get_env("SLASH_COMMAND_TOKEN") || raise "Set SLASH_COMMAND_TOKEN !!"
+ config :echo, :slash_command_token, slash_command_token
```

## 隠し機能を作る

`/echo` コマンドですが、隠し機能として猫の画像を見る機能を付けてみましょう。　[Cat as a service (CATAAS)](https://cataas.com/)　を使えばランダムな猫の画像や、テキスト付きの猫の画像を見ることができます。素晴らしいサービスですね。

今回は `/echo cat` でランダムな猫の画像、 `/echo cat hello` で hello という文字の入った猫の画像を表示するという仕様で改造していきます。

```lib/echo_web/slack_controller.ex
 defmodule EchoWeb.SlackController do
   use EchoWeb, :controller
 
+  def index(conn, %{"text" => "cat"}) do
+    render(conn, "cat.json", %{message: nil})
+  end
+
+  def index(conn, %{"text" => "cat " <> message}) do
+    render(conn, "cat.json", %{message: message})
+  end
+
   def index(conn, %{"text" => text}) do
     render(conn, "index.json", %{text: text})
   end
```

elixir の文字列のパターンマッチを使うことでとても簡単に実装することができました。 与えられた `text` が `"cat"` なら1つ目の節、 `text` が `"cat "` から始まるなら2つ目の節、それ以外ならば3つ目の節にマッチします。

あとは画像の URL を含むメッセージを返せば良いだけです。

```lib/echo_web/slack_view.ex
 defmodule EchoWeb.SlackView do
   use EchoWeb, :view
 
+  def render("cat.json", %{message: nil}) do
+    %{
+      blocks: [
+        %{
+          type: "image",
+          title: %{
+            type: "plain_text",
+            text: "cat",
+            emoji: false
+          },
+          image_url: "https://cataas.com/cat?width=200",
+          alt_text: "cat image"
+        }
+      ]
+    }
+  end
+
+  def render("cat.json", %{message: message}) do
+    %{
+      blocks: [
+        %{
+          type: "image",
+          title: %{
+            type: "plain_text",
+            text: "message cat",
+            emoji: false
+          },
+          image_url: "https://cataas.com/cat/says/#{message}?width=200",
+          alt_text: "message cat image"
+        }
+      ]
+    }
+  end
+
   def render("index.json", %{text: text}) do
     %{
       blocks: [
         %{
           type: "section",
           text: %{
             type: "plain_text",
             text: text,
             emoji: true
           }
         }
       ]
     }
   end
 end
```

試しに `/echo cat LGTM` を実行してみると良い感じの猫の画像が出ました。これでいつでも猫の画像を見ることができますね。

![Screenshot from 2019-12-07 16-46-48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49469/6526fd2d-9d4c-528a-769d-a1c23718a141.png)

## 所感
- Elixir / Phoenix を使うことで簡単に slash command を作ることができた
    - Elixir のパターンマッチのおかげで直観的に理解しやすい分岐を書くことができた
    - phoenix の view を使うことで `Map` を返す関数を書けば json として返してくれて楽だった
    - Plug を使うことでシンプルにトークンの検証を行うことができた

ちなみにこの後コンテナ化して Cloud Run に乗せてみました。初回呼び出し時には slash command のタイムアウトである 3000ms にひっかかりましたが、それ以降はそれなりに安定してレスポンスを返えすことができていました。