LoginSignup
28
26

More than 5 years have passed since last update.

ElixirでSlack Botを作った with Qiita API

Last updated at Posted at 2018-05-27

(この記事は、「fukuoka.ex(その2) Elixir Advent Calendar 2017」の8日目、Slack Advent Calendar 2017の11日目です)

昨日は@koga1020さんの「Elixirのパーサーコンビネータライブラリ Combine入門」でした!

はじめに

昨日の@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

スクリーンショット 2018-05-24 8.36.25.png

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

スクリーンショット 2018-05-24 8.36.34.png

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

スクリーンショット 2018-05-24 8.36.55.png

「Add a Bot User」をクリックすると下記のような画面が出るので「Add Bot User」をクリック。
スクリーンショット 2018-05-24 8.37.07.png

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

スクリーンショット 2018-05-24 8.37.22.png

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

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

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

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を取得します。

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をパース

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ライブラリのアクセス方法がドメインとパスとで別れるため分割します。

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を第一引数としてわたします。

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. メッセージごとの処理

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

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_notificationmessage の場合handlerを実行するようにします。

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

GenServer.cast(__MODULE__, {:push, func})

この時注意しないといけないのは func は二つの引数を受け取る関数でないとエラーになります。

6. レスポンス

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

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に対してメンションが送られてきた場合のみ、メッセージの解読処理を行います。

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行ずつ分割し、1行ずつ解読を行います。これで複数行メッセージを送って同時にいろんな記事を取れるようにしました。

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の形式で送られてくるので、解読を行います。

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.filternil を取り除きます。
次のEnum.mapでkey valueのマップに変換し、Enum.reduce で一つのマップに変換します。
最後に Map.merge でデフォルトのオプションとマージしてオプションを作成します。

動作

実際に送って見ます

スクリーンショット 2018-05-27 10.28.08.png

できたー!!

最後に

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

明日は@piacere_exさんの「Elixirでデータ分析入門#1:様々なデータをインプットする」になります!

:stars::stars::stars::stars::stars: 満員御礼!Elixir MeetUpを6月末に開催します :stars::stars::stars::stars::stars:
※応募多数により、増枠しました!!

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

fukuokaex.png

28
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
26