(この記事は、「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
ここの「Create a Slack app」をクリック
すると、下記のようなダイアログが出るので、Botの名前を入力しBotを作成するSlackのWorkSpaceを選択。
下記のような画面が出てくるので「Bots」を選択
「Add a Bot User」をクリックすると下記のような画面が出るので「Add Bot User」をクリック。
次に「Install App」へ移動し、「Install App to Workspace」をクリック
あとはこの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_notification
か message
の場合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.filter
で nil
を取り除きます。
次のEnum.mapでkey valueのマップに変換し、Enum.reduce
で一つのマップに変換します。
最後に Map.merge
でデフォルトのオプションとマージしてオプションを作成します。
動作
実際に送って見ます
できたー!!
最後に
- Slack RTMへの接続すればSlackからいろいろとメッセージを送受信できるので夢が広がります。
- 実装自体もシンプルにできましたし、メッセージのパース処理も簡単にできたので、もっと汎用的に使えるようにしたいですね。
明日は@piacere_exさんの「Elixirでデータ分析入門#1:様々なデータをインプットする」になります!
満員御礼!Elixir MeetUpを6月末に開催します
※応募多数により、増枠しました!!
「fukuoka.ex#11:DB/データサイエンスにコネクトするElixir」を6/22(金)19時に開催します。
自分もLTで発表させていただきますので、ぜひ興味ある方はご参加ください!