ElixirでSlackBotを簡単に作れるライブラリを作ったよ

  • 47
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

SlackのBotを簡単に構築できるライブラリを作りました。

私が調べた限り、ElixirにはSlackのBotを作るライブラリとして、
Elixir-Slackslackerがありますが、求めているものとしてこれでは足りなかったため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のプロジェクトを作成できます。

lib/echo.ex
ここに、先ほどのsampleを書く。

mixにはライブラリの依存を取得してくれる機能もあります。
RubyでいうBundlerです。

mix.exs
def deps do
  [
    {:websocket_client, github: "jeremyong/websocket_client"},
    {:slacker,  "~> 0.0.1"},
    {:sloth, "~> 0.0.2"}
  ]
end

slothは、websocket_clintslackerに依存しています。
websocket_clientはerlangで記述されているものです。
elixirはerlangの上に構築されている言語のためerlangの資産を活用することができます。

mix deps.get

上記コマンドで依存ライブラリを取得します。

mix.exs
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メソッドで受け取ります。

lib/sloth/slacker.ex
def handle_cast({:handle_incoming, type = "message", send_data}, state) do
  # 略
end

slothでは、RTMAPIのうち typeがmessageのもののみを扱うようにパターンマッチしていますが、
slackerを直接利用すれば他のイベントの受信に応じて処理を行うことも出来ます。
slothもそのうち拡張するかもしれません。

lib/sloth/slacker.ex
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の管理をするモジュールです。

lib/sloth/plug_manager.ex
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/2get/1get_add/0
pluginの追加、取得を行います。

Sloth.Plugin

最後はplugin/2マクロを提供する。Sloth.Pluginです。

lib/sloth/plugin.ex
defmacro plugin(pattern, function) do
  quote do
    @plugin { unquote(pattern), unquote(function) }
  end
end

やっていることは単純で、plugin attributeに、パターンとメソッドを格納しています。
attributeは、配列になっていて、
@plugin ~~~~が複数あれば、配列の追加されていきます。

lib/sloth/plugin.ex
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/2terminate/2init/1の実装と、
@before_compileの定義です。

terminate/2については空のほうが、詳細がログ(コンソール)に出てたので、
空になってます・・・。よいやり方は正直わかってないです。

handle_cast/2は、Sloth.Slackerからのcastを受けているcallbackメソッドです。
引数に受けた、メソッドをそのまま呼んでいるだけです。

@before_compileは、compile前に最後?に呼ばれる処理の定義です。

lib/sloth/plugin.ex
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はモジュール名です。

名称未設定_2.png

その後、Sloth.Slackerからのコールバックを受けるGenServerのプロセスを作成しています。

まとめ

名称未設定_2.png

pluginでメソッドを登録。
登録されたpluginの中でslackからのメッセージが正規表現にマッチするものを探す。
マッチしたメソッドを呼び出し。

やっていることは単純です。
単純ですが、ElixirのOTP周り、マクロのメタプログラミング。
この2つが受け持つ範囲、やれることを調べてこの構成にするまでに結構時間がかかりました。

現状はシンプルな機能ですが、
今後業務でのslackbotに使っていくので、
自分が欲しいと思った機能は追加されていくと思いますw

課題

Testがあんまりかけていないです。

おまけ

sloth_example

slothを組み込んで、todoを作ったexampleです。
OTPのApplicationまで使っているので参考にしてもらえると幸いです。

issue,PullRequestも受け付けています!
よかったら使ってみてください:innocent: