ElixirでSlack Botを作った with Qiita API

（この記事は、[「fukuoka.ex(その2) Elixir Advent Calendar 2017」](https://adventar.org/calendars/2871)の8日目、[Slack Advent Calendar](https://qiita.com/advent-calendar/2017/slack) 2017の11日目です）

昨日は@koga1020さんの[「Elixirのパーサーコンビネータライブラリ Combine入門」](https://qiita.com/koga1020/items/e102dc56315800704602)でした！

# はじめに

昨日の@koga1020に引き続き、fukuoka.exキャストとして「季節外れのfukuoka.ex(その2) Elixir Advent Calendar」に参加させていただくことになりました、@kobatakoといいます。これからfukuoka.exのアドバイザーとキャストの方々とElixirを盛り上げられるよう頑張っていきます！よろしくお願いします。

# Slack Botで何がしたいか

Qiitaの投稿だったり、タグで投稿リストをとったり最新の投稿見たりをSlack上でできれば楽だなぁっと思い作りました。
今のところユーザーの投稿リストを取得するのはできたので次はタグや全体での最新の投稿が取れるような実装をする予定です。

## 概要

モジュールを下記のものを使いました。

HTTPoison : HTTPクライアント
Socket : Socketクライアント（WebSoketで利用）
Poison : Jsonライブラリ

流れとしてはHTTPoisonで認証とWebSocketのURLを取得、WebSocketで接続。
そのあとはRTMでイベントを取得後、任意のハンドラをコールし、HTTPoisonでQiita APIにリクエスト、その結果をSlackに送信する流れです。

## もうちょっと詳しく
### 最初にいろいろすること、概要

一番最初はTokenを取得します。これがないと何も始まらないです。
Tokenの取得方法は他の記事とかに記載されてますが、ここでも同じようなことを書きます。

### 1. Slackのページへ

Slack APIのページへ
https://api.slack.com/slack-apps

<img width="1232" alt="スクリーンショット 2018-05-24 8.36.25.png" src="https://qiita-image-store.s3.amazonaws.com/0/215989/0312097c-38c0-a853-ec9c-f69c31dd310c.png">

ここの「Create a Slack app」をクリック
すると、下記のようなダイアログが出るので、Botの名前を入力しBotを作成するSlackのWorkSpaceを選択。

<img width="576" alt="スクリーンショット 2018-05-24 8.36.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/215989/ee655c4a-e858-8b6b-ef54-3e632d0d5cd9.png">

下記のような画面が出てくるので「Bots」を選択

<img width="820" alt="スクリーンショット 2018-05-24 8.36.55.png" src="https://qiita-image-store.s3.amazonaws.com/0/215989/7ec0252e-fcf6-9557-1d3f-bdfb4d33e008.png">

「Add a Bot User」をクリックすると下記のような画面が出るので「Add Bot User」をクリック。
<img width="838" alt="スクリーンショット 2018-05-24 8.37.07.png" src="https://qiita-image-store.s3.amazonaws.com/0/215989/7fa901d3-24a7-bb22-e603-c546a5f3e9f0.png">

次に「Install App」へ移動し、「Install App to Workspace」をクリック

<img width="829" alt="スクリーンショット 2018-05-24 8.37.22.png" src="https://qiita-image-store.s3.amazonaws.com/0/215989/fd06bcdf-7433-02eb-c4ca-200e43487310.png">

あとはこのBotを認証するかどうかの画面が出てくるので「Authorize」をクリックするとTokenが取得できます。

### 2. アプリケーション側での認証

まずは全体的流れですが、下記のようになります。

```Elixir
def connect(token) do
  fetch_body(token)
  |> parse_url()
  |> connect_websocket()
  |> loop()
end
```

`connect` 関数にSlackでに接続するためのTokenを引数で渡します。`fetch_body` でSlackに接続先のURLを取得するためリクエストを投げます。`parse_url` でURLをパースし、 `connect_websocket` でSlackに接続を行います。
最後の`loop` で常にメッセージを待ち受けてる状態になります。

実際にBotとして動かすプログラムを書いていきます。
まずはHTTPoisonでWebSocketで接続するためのURLを取得します。

```Elixir
defp fetch_body(token) do
  url = "https://slack.com/api/rtm.start?token=#{token}&include_locale=true"
  case HTTPoison.get! url do
    %{status_code: 200, body: body} ->
      Poison.Parser.parse!(body, keys: :atoms)
    %{error: "account_inactive"} = error ->
      IO.inspect error
  end
end
```

```
body {
・・・
url: "wss://cerberus-xxxx.lb.slack-msgs.com/websocket/lNP_9syWfeINxO0fxHO06eRFch8YrI8T-bQIidVOSqe2V0w3bRsr0Y6F0cQpzG5tuB-ipBwk0vm-DIOwbSF-KB1GN2H0F3X6mkLy7dstEefvr_6pRijL_rBOURnpPt9iFD54EuPytkzw6BpA/2",

・・・
}
```

body内に`url`として入っており、その接続先に対してWebSocketで接続するとリアルタイムでメッセージを受信したりすることが可能になります。

### 3. WebSocketへの接続

WebSocketのURLをパース

```Elixir
def parse_url(%{url: url}) do
  with %{"uri" => uri} <- Regex.named_captures(~r/wss:\/\/(?<uri>.*)/, url),
    [domain| path] <- String.split(uri, "/") do
    %{domain: domain, path: Enum.join(path, "/")}
  end
end
```
Socketライブラリのアクセス方法がドメインとパスとで別れるため分割します。

```Elixir
def connect_websocket(%{domain: domain, path: path}) do
  Socket.Web.connect!(domain, secure: true, path: "/" <> path)
end
```

`Socket.Web.connect!`でWebSocketへ接続するための `socket` を取得できるのでこの `socket` を利用してWebSocketからメッセージの受信、送信ができるようになります。

### 4. Slackからメッセージを受け取る

Slackからメッセージを受け取るためには`Socket.Web.recv!` 関数に 3 で取得したSocketを第一引数としてわたします。

```Elixir
defp loop(socket) do
  case socket |> Socket.Web.recv!() do
    {:text, text} ->
      Poison.Parser.parse!(text, keys: :atoms)
      |> message(socket)
      loop(socket)
    {:ping, _} ->
      t = DateTime.utc_now() |> DateTime.to_string()
      socket |> Socket.Web.send!({:text, Poison.encode!(%{type: "ping", id: t})})
      loop(socket)
  end
end
```
`{:text, text}` がメインでの処理、`{:ping, _}`では定期的にリクエストが来る（っぽい）んでその都度返信を返すようにします。

### 5. メッセージごとの処理

とりあえず、２パターンのみ実装

```Elixir
defp message(%{type: "desktop_notification"} = m, socket) do
  {ts, _} = Integer.parse(m.event_ts)
  r = %SlackResponse{from: m.title, content: m.content, channel: m.channel,
    ts: DateTime.to_string(DateTime.from_unix!(ts))
  }
  Enum.map(GenServer.call(__MODULE__, :fetch), fn f -> f.(:desktop_notification, r) end)
  |> response_handle(socket)
end

defp message(%{type: "message"} = m, socket) do
  {ts, _} = Integer.parse(m.ts)
  r = %SlackResponse{from: m.user, text: m.text, channel: m.channel,
    ts: DateTime.to_string(DateTime.from_unix!(ts))
  }
  Enum.map(GenServer.call(__MODULE__, :fetch), fn f -> f.(:message, r) end)
  |> response_handle(socket)
end
```

typeで `desktop_notification`がbotに対してメンションを送った時に呼ばれる処理。基本的にはこっちの時に色々と処理をするようにする。
`message` ではこのbotがいるchannelに対してメッセージが通知されると `message` タイプトしてメッセージを受け取ります。
`Enum.map(GenServer.call(__MODULE__, :fetch), fn f -> f.(:message, r) end)`ではこのモジュールをGenServerと起動し、GenServerにhandlerを登録しておきます。handlerはRTMのメッセージタイプが`desktop_notification` か `message` の場合handlerを実行するようにします。

GenServerへhandlerを登録する方法か下記に載せています

```Elixir
GenServer.cast(__MODULE__, {:push, func})
```
この時注意しないといけないのは `func` は二つの引数を受け取る関数でないとエラーになります。

### 6. レスポンス

最終的なレスポンスを返します。

```Elixir
def response({:send, message}, socket) do
  socket |> Socket.Web.send!({:text, Poison.encode!(message)})
end
```
messageがJsonできているのでencodeしメッセージを送ります。
これで、SlackのRTMで認証、メッセージの受信、送信まで実装できました！
次はSlackからのメッセージを解読してQiitaの情報を送信する仕組みを実装していきます。

## Qiita API

Qiita APIを実装するにはまずはAPIではおなじみのTokenが必要になってきます。
TokenはQiita のページから「設定」 -> 「アプリケーション」 -> 個人用アクセストークンのところで「新しくトークンを発行する」を開きます。
そうするとTokenの説明とスコープを決めます。スコープは読み込みさえできればいいのでとりあえずは「read_qiita」にしておきます。

### 1. Slackからのメッセージを解読

Tokenも手に入れたので実際にQiitaの記事を取れるよう、実装を進めていきます。
HTTPクライアントはHTTPoisonを使います。
このQiitaの記事を取得するAPIをSlackでメッセージがきたタイミングで実行するようにします。
今回はこのBotに対してメンションが送られてきた場合のみ、メッセージの解読処理を行います。

```Elixir
def callback_handle_get_items(:desktop_notification, m) do
  text = String.split(m.content, "\n")
  |> get_items()
  {:send, %ExAviso.SlackRequest{
    type: "message",
    channel: m.channel,
    text: Enum.join(text, "\n"),
    ts: DateTime.to_unix(DateTime.utc_now()),
  }}
end
```
`get_items` でSlack上に表示するtextを作成し、送信するjsonを作成します。

まずはさらっと、受け取ったメッセージを1行ずつ分割し、１行ずつ解読を行います。これで複数行メッセージを送って同時にいろんな記事を取れるようにしました。

```Elixir
def get_items([head| tail]) do
  resp = if Regex.match?(~r/getItems/, head) do
    option = ExAviso.Slack.get_option(head, @default_items_option)
    [token: token] = Application.get_all_env(:qiita)
    headers = [{"Authorization", "Bearer #{token}"}]
    url = "https://qiita.com/api/v2/users/#{option["user"]}/items?page=#{option["page"]}&per_page=#{option["per_page"]}"
    body = case HTTPoison.get!(url, headers) do
        %{status_code: 200, body: body} ->
          Poison.Parser.parse!(body, keys: :atoms)
    end
    e = for %{title: title, url: url} <- body, do: title <> "[" <> url <> "]"
    Enum.join([e], "\n")
  else
    ""
  end
  [resp| get_items(tail)]
end
```
受け取ったメッセージで「getItems」が含まれている時にQiitaに記事を取得しに行くようにします。
そのリクエスト時に「ユーザー名」「ページ番号」「ページごとの記事の数」をオプションとして指定して送ることもできるようにしています。

### 2. オプション解読

オプションはkey=valueの形式で送られてくるので、解読を行います。

```Elixir
def get_option(line, default) do
  String.split(line, " ")
  |> Enum.map(&Regex.named_captures(~r/(?<name>.*)=(?<value>.*)/, &1))
  |> Enum.filter(&(&1 != nil))
  |> Enum.map(&(%{&1["name"] => &1["value"]}))
  |> Enum.reduce(%{}, &(Map.merge(&1, &2)))
  |> Map.merge(default, fn _k, v1, _v2 -> v1 end)
end
```
最初の `Enum.map` でオプションの名前、値でマップを作成し `Enum.filter` で `nil` を取り除きます。
次のEnum.mapでkey valueのマップに変換し、`Enum.reduce` で一つのマップに変換します。
最後に `Map.merge` でデフォルトのオプションとマージしてオプションを作成します。

## 動作

実際に送って見ます

<img width="688" alt="スクリーンショット 2018-05-27 10.28.08.png" src="https://qiita-image-store.s3.amazonaws.com/0/215989/e52d51be-39ee-5aaf-3631-d6ff04831304.png">

できたー！！


# 最後に

* Slack RTMへの接続すればSlackからいろいろとメッセージを送受信できるので夢が広がります。
* 実装自体もシンプルにできましたし、メッセージのパース処理も簡単にできたので、もっと汎用的に使えるようにしたいですね。


明日は@piacere_exさんの[「Elixirでデータ分析入門#1：様々なデータをインプットする」](https://qiita.com/piacere_ex/items/f7077f0ff1b1a6c7a959)になります！

:stars::stars::stars::stars::stars:　満員御礼！Elixir MeetUpを6月末に開催します　:stars::stars::stars::stars::stars:
<font color="red" bold>※応募多数により、増枠しました!!</font>

[「fukuoka.ex#11：DB／データサイエンスにコネクトするElixir」](https://fukuokaex.connpass.com/event/87241/)を6/22（金）19時に開催します。
自分もLTで発表させていただきますので、ぜひ興味ある方はご参加ください！

![fukuokaex.png](https://qiita-image-store.s3.amazonaws.com/0/215989/16ba72c0-05ac-ac73-79bd-efc39216f436.png)