SlackのBotを簡単に構築できるライブラリを作りました。
私が調べた限り、ElixirにはSlackのBotを作るライブラリとして、
Elixir-Slack、slackerがありますが、求めているものとしてこれでは足りなかったためslackerの上に構築しました。
それがこちらです。
Sloth
使い方と構成、仕組みの解説をしたいと思います。
HowTo
defmodule Echo do
use Sloth.Plugin
plugin ~r/^echo (.*)$/, :echo
def echo(send_data, captures \\ []) do
Sloth.Slacker.say(send_data["channel"], List.first(captures))
end
end
上記sampleは、echo ほげほげ
のような発言に対して、
ほげほげ
と返すBotです。
use Sloth.Plugin
を記述することでplugin/2
マクロを使用できるようになります。
plugin/2
マクロには、Botに反応させたいテキストの正規表現と、
その正規表現にマッチした際に実行するメソッドを渡します。
sampleでは~r/^echo (.*)$/
と:echo
となっています。
echo
メソッドはsend_data
(slackでの発言の情報)とcaptures
(正規表現のキャプチャ)を引数に受け取ります。
send_data
内には発言されたチャンネル、発言したユーザーのID、発言のテキスト、発言の時間が含まれています。
それらを元にSloth.Slacker.say/2
メソッドでSlackに発言します。
Elixirを触ったことがない人にもElixirを触るきっかけとして使ってもらうために、
上記sampleが動く過程も解説します。内部構成と仕組みをさっさと説明しろ。という方はスクロールしてください。
Elixirのインストールについては、Qiitaの別記事、公式サイトをご確認ください。
elixirがインストールできたら先に進みます。
mix new echo
cd echo
mixコマンドを使用することでelixirのプロジェクトを作成できます。
ここに、先ほどのsampleを書く。
mixにはライブラリの依存を取得してくれる機能もあります。
RubyでいうBundlerです。
def deps do
[
{:websocket_client, github: "jeremyong/websocket_client"},
{:slacker, "~> 0.0.1"},
{:sloth, "~> 0.0.2"}
]
end
slothは、websocket_clint
とslacker
に依存しています。
websocket_client
はerlangで記述されているものです。
elixirはerlangの上に構築されている言語のためerlangの資産を活用することができます。
mix deps.get
上記コマンドで依存ライブラリを取得します。
def application do
[applications: [:logger, :websocket_client, :slacker, :sloth]]
end
アプリケーションの起動時に読み込む(実行する)ライブラリを指定します。
export SLACK_TOKEN=xxxxxxxxxxxxxxxx
Slackのbotのトークンを環境変数に設定します。
Botの作成はこちらから行ってください。
https://my.slack.com/services/new/bot
bot作成後、botを常駐させたいチャンネル、グループにbotを招待してください。
ies -S mix
Echo.start_link
上記コマンドでアプリを立ち上げます。
これで、slackへ接続できた旨のメッセージが流れればOKです。
slack上でecho ほげほげ
と発言してみてください。
内部構成と仕組み
Sloth.Supervisor
-> Sloth.Slacker
-> Sloth.PlugManager
1つのsupervisor
プロセスから、2つのworker
を生成しています。
それぞれストラテジーとしてone_for_one
を指定しています。
Supervisorはプロセスの監視をしてくれるプロセスです。
workerプロセスが終了orクラッシュした際に検知し再起動などの動作を行います。
その動作がone_for_one
です。
これは、workerがクラッシュしたらそのworkerのみ再起動するという動作です。
他にプロセスツリー全体を再起動などの動作があります。
Sloth.Slacker
Sloth.Slackerはslackerを使用したもので、
slackのRTMAPIとの接続、RTMからのメッセージの受信、メッセージの送信、
取得したメッセージを正規表現解析して、マッチしたメソッドの実行をしています。
slackerはGenserverを実装しています。
RTMからメッセージ受信をhandle_cast
のcallbackメソッドで受け取ります。
def handle_cast({:handle_incoming, type = "message", send_data}, state) do
# 略
end
slothでは、RTMAPIのうち typeがmessageのもののみを扱うようにパターンマッチしていますが、
slackerを直接利用すれば他のイベントの受信に応じて処理を行うことも出来ます。
slothもそのうち拡張するかもしれません。
def handle_cast({:handle_incoming, type = "message", send_data}, state) do
Logger.debug "#{type} -> #{inspect send_data}"
PlugManager.get_all
|> Enum.each(fn({module, functions}) ->
Enum.each(functions, fn({regex, func}) ->
match = Regex.run(regex, send_data["text"])
if match do
[_text | captures] = match
GenServer.cast(module, {:call_plugin, func, [send_data, captures]})
end
end)
end)
{:noreply, state}
end
PlugManagerの説明は後述しますが、
そこからpluginの一覧を取得して、ループしつつ、
受信したテキストにマッチするpluginを探します。
マッチしたらそのメソッドを呼び出しています。
pluginの実行を別プロセスにしたかったので、
GenServer.cast(module, {:call_plugin, func, [send_data, captures]})
GenServerのcastで処理を発火するようにしています。
Sloth.PlugManager
名前の通り、pluginの管理をするモジュールです。
defmodule Sloth.PlugManager do
def start_link do
Agent.start_link(fn -> %{} end, name: __MODULE__)
end
def add(module, plugin) do
Agent.get_and_update(__MODULE__, fn(state) ->
Map.get_and_update(state, module, fn(value) ->
case value do
nil -> {value, plugin}
_ -> {value, value ++ plugin}
end
end)
end)
end
def get(module) do
Agent.get(__MODULE__, fn(state) -> Map.get(state, module) end)
end
def get_all do
Agent.get(__MODULE__, fn(state) -> state end)
end
end
pluginの管理方法として、Agentを使用しています。
やっていることは単純にAgentのラッパーです。
start_link/0
でAgetnに初期値、空のMapを渡します。
add/2
、get/1
、get_add/0
で
pluginの追加、取得を行います。
Sloth.Plugin
最後はplugin/2
マクロを提供する。Sloth.Pluginです。
defmacro plugin(pattern, function) do
quote do
@plugin { unquote(pattern), unquote(function) }
end
end
やっていることは単純で、plugin attributeに、パターンとメソッドを格納しています。
attributeは、配列になっていて、
@plugin ~~~~
が複数あれば、配列の追加されていきます。
defmacro __using__(_env) do
quote do
require Logger
import Sloth.Plugin
use GenServer
@plugin []
Module.register_attribute(__MODULE__, :plugin, accumulate: true)
def terminate(reason, state) do
end
def handle_cast({:call_plugin, func, args}, state) do
apply(__MODULE__, func, args)
{:noreply, state}
end
def init(state) do
{:ok, state}
end
@before_compile unquote(__MODULE__)
end
end
__using__/1
メソッドは、
use Sloth.Plugin
により実行されます。
import Sloth.Plugin
は、一見自身をimportしてどうするの?と思いますが、
この処理が実行されるコンテキストは、use
マクロを使用しているモジュール側になるため、
sampleの場合だとEchoモジュールがimport Sloth.Plugin
を実行していることと同じです。
これで、plugin/2
マクロを取り込んでいます。
ここで主にやっていることは、@plugin attributeの定義、
GenServerのcallbackメソッド、handle_cast/2
、terminate/2
、init/1
の実装と、
@before_compile
の定義です。
terminate/2
については空のほうが、詳細がログ(コンソール)に出てたので、
空になってます・・・。よいやり方は正直わかってないです。
handle_cast/2
は、Sloth.Slackerからのcastを受けているcallbackメソッドです。
引数に受けた、メソッドをそのまま呼んでいるだけです。
@before_compileは、compile前に最後?に呼ばれる処理の定義です。
defmacro __before_compile__(env) do
quote do
def start_link do
Sloth.PlugManager.add(__MODULE__, @plugin)
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
end
end
@plugin attributeをそのままPlugManagerのAgentにぶっこんでます。
マップのkeyはモジュール名です。
その後、Sloth.Slackerからのコールバックを受けるGenServerのプロセスを作成しています。
まとめ
pluginでメソッドを登録。
登録されたpluginの中でslackからのメッセージが正規表現にマッチするものを探す。
マッチしたメソッドを呼び出し。
やっていることは単純です。
単純ですが、ElixirのOTP周り、マクロのメタプログラミング。
この2つが受け持つ範囲、やれることを調べてこの構成にするまでに結構時間がかかりました。
現状はシンプルな機能ですが、
今後業務でのslackbotに使っていくので、
自分が欲しいと思った機能は追加されていくと思いますw
課題
Testがあんまりかけていないです。
おまけ
slothを組み込んで、todoを作ったexampleです。
OTPのApplicationまで使っているので参考にしてもらえると幸いです。
issue,PullRequestも受け付けています!
よかったら使ってみてください