十二月
2
日曜日

DuctでSlack Botを作成する

以前の 『TwitterのリストタイムラインをSlackへ連携する』 で作成した mountベースのSlack Bot chabonze をClojureのサーバサイドフレームワークである Duct 0.7.0-beta1 を使って書き直した際のメモ。


目的

今回の目的は以下の三つ。

  1. Ductとその基軸となる Integrant の基本的な使い方を学ぶ
  2. chabonzeの機能を基本機能と拡張機能に分けモジュール化する
  3. core.asyncを使い倒す

Integrantについて

Integrantはデータ指向なDIフレームワークでコンフィグレーションマップのコンポーネント定義に基づきコンポーネント群を構築する。

コンフィグレーションマップ

コンフィグレーションマップとはClojureのマップであり、edn形式のファイルとして記述することもできる。
コンポーネント定義はコンポーネントを一意に表すキーとそのコンポーネント生成に必要なパラメータから構成される。

例えば以下のvar config:adapter/jetty:handler/greet の二つのコンポーネントを定義しているコンフィグレーションマップである。

(require '[integrant.core :as ig])

(def config
  {:adapter/jetty {:port 8080, :handler (ig/ref :handler/greet)}
   :handler/greet {:name "Alice"}})

この例では :adapter/jetty というコンポーネントを定義し、生成時のパラメータとして :port 80 と :handler には 他コンポーネントである :handler/greet を定義。 同様に :handler/greet には :nameAlice を定義している。

パラメータで他コンポーネントを指定する場合は ref関数を使って参照を渡す必要がある。
またコンフィグレーションマップをednで記述する場合、関数ではなく以下のように Tagged literal を使用する。

{:adapter/jetty {:port 8080, :handler #ig/ref :handler/greet}
 :handler/greet {:name "Alice"}}

コンポーネントのライフサイクル

コンフィグレーションマップはただのデータであり、コンポーネントの生成、破棄、一時停止、復帰といったライフサイクルに応じた処理をコードで記述する必要がある。

Integrantでは以下のようにコンポーネントを表すキーをディスパッチ値としたClojureのマルチメソッドとして init-keyhalt-key! で生成と破棄の手続きを、suspend!resume で 一時停止と復帰の手続きを記述する。

(require '[ring.adapter.jetty :as jetty]
         '[ring.util.response :as resp])

(defmethod ig/init-key :adapter/jetty [_ {:keys [handler] :as opts}]
  (jetty/run-jetty handler (-> opts (dissoc :handler) (assoc :join? false))))

(defmethod ig/init-key :handler/greet [_ {:keys [name]}]

  (fn [_] (resp/response (str "Hello " name))))

このマルチメソッドの定義は任意のネームスペース上で構わないがIntegrantによるコンフィグレーションマップの初期化処理を行う前にネームスペースがロード済みになっている必要がある。
コンポーネントの数が増えてくるとコンポーネントの種類に応じてネームスペースを分割、管理したくなるが、ネームスペースが増えてくると各ネームスペースがロード済みであることを担保するのが煩わしくなってくる。

そのような場合はマルチメソッドが定義されているネームスペースとコンポーネントのキーであるキーワードを揃えておくと、Integrantの load-namespace 関数によって一括でロードすることができる。

Ductについて

DuctはIntegrantをベースにモジュール化したサーバサードアプリケーションを構築するためのフレームワークでIntegrantを単体で利用するより以下の恩恵を得られる。

  • Leiningen用のテンプレートとプラグインが提供されておりプロジェクトの作成からREPL駆動開発まで必要な環境が直ぐに用意できる。
  • Integrantのコンフィグレーションマップを包含したコンフィグレーション機構をもっており、プロファイルやモジュールを扱うことができる。
  • Logging, Database, HTTPルータといった既存モジュールが用意されている。

モジュール

Ductにおけるモジュールとは特定の機能を有するコンポーネントとそのデフォルトのコンフィグレーションをパッケージングしたもので通常はライブラリとして提供される。

モジュール自身もコンフィグレーションマップで管理されるコンポーネントの一つであり、その実態はコンフィグレーションマップを書き換える関数となっている。
この関数はコンフィグレーションマップを受け取り、自モジュールが提供するコンポーネントのデフォルトのコンフィグレーションを追加した新たなコンフィグレーションマップを返す。

この仕組みによりモジュールの利用者はモジュールが提供する全てのコンポーネントのコンフィグレーションを自分で定義する必要が無くなり、カスタマイズしたい箇所のみを上書きすれば良くなる。

なおモジュールの作り方についてはドキュメントがあまり無いため、使用するDuctのバージョンに対応した公式モジュールのソースコードを参考にすると良さそうだ。

Botの基本機能

Botに必要な基本機能を定義する。

Slackイベントの受信

Slack上で発生する各種イベントを受信する機能。
受信方法には

の二つがある。

Event APIはWebhookのようにBot側にHTTPエンドポイントを用意しイベントが起こる度にSlack側から呼んでもらう方式。お手軽な反面、インターネットからのインバウンド通信を許可する必要があるためBotの配置箇所に制約が発生する。

一方、RTM APIはBot側がWebsocketクライアントとなりWebsocketを通じてイベントを受信する方式。Websocketさえ通れば良く配置箇所に制約が生まれにくいという利点がある。

今回は配置箇所の制約が少ないRTM APIによるメッセージ受信を使用する。

メッセージによるコマンド実行

受信したSlackイベントのうち、Bot宛ての / から始まるメッセージをコマンドとして解釈し処理を実行する機能。

@<botname> /<command> <args>

なお Slash Command の利用はEvent APIと同様にBot側にHTTPエンドポイントを持ちたくなかったので今回は見送りとした。

メッセージ送信

RTM APIを利用したシンプルなメッセージ送信とSlack Web APIによるカスタマイズ可能なメッセージ送信機能。

せっかくWebsocketで接続しているので全てRTM APIでメッセージ送信を行いたいところだが、残念ながらRTM APIではシンプルなメッセージしか送れないため必要に応じてSlack Web API経由でメッセージを送信できるよう二種類のメッセージ送信方法を用意。

データストア

任意のデータを永続化する機能。 ファイルに保存。

拡張機能(Twitter連携)

認可

ユーザに成り代わりBotがTwitterへアクセスするための認可機能。
コマンドでPIN-based OAuthを利用しアクセストークンの取得まで行う。

リスト・検索結果購読

任意の間隔で指定リストまたは検索結果の最新ツイートを指定のチャネル上の表示する機能。 コマンドから購読の追加、削除と購読済みリストの一覧表示。

コンポーネントの構成

目的(2) chabonzeの機能を基本機能と拡張機能に分けモジュール化する のため、Ductモジュールの構成を基本と拡張に分ける。

モジュール module.bot がBot基本モジュール、 module.twitter が拡張モジュールとなる。

拡張モジュールは今のところ module.twitter の一つだけだが、やりたきことに応じて増えていく予定。
(connpassから興味のある勉強会情報のフィードや、Mastodonのトゥート購読等の拡張モジュールが欲しい)

module.bot

module.botBotの基本機能を実装したDuctモジュール。

Slack listener

Slack listenerは メッセージによるコマンド実行 を行うためのコンポーネント。
後述のSlack RTM API Clientを通じて自分宛のメンションを受け取り、それがコマンドであれば実行する。

初期化時にコンフィグレーションマップ内の :jp.nijohando.chabonze/commandderiveしたコンポーネント群の参照を受け取りこれを実行可能なコマンドとして認識する。

(defn listener-config
  []
  {:jp.nijohando.chabonze.bot/listener
   {:rtm (ig/ref :jp.nijohando.chabonze.bot.slack/rtm)
    :store (ig/ref :jp.nijohando.chabonze.bot/store)
    :logger (ig/ref :duct/logger)
    :commands (ig/refset :jp.nijohando.chabonze/command)}})

このコンポーネント群の参照の受け取りはIntegrantの refset を使用する。
この関数はIntegrant 0.7.0-alpha1から追加されており指定キーの複数のコンポーネントの参照をセットで取得することができる。

また :jp.nijohando.chabonze/command をderiveしたコンポーネントは以下の jp.nijohando.chabonze.bot.command.Command プロトコルを実装するようにし、Slack Listenerはこのプロトコルを通じてコマンドを管理する。

(defprotocol Command
  (name [this])
  (description [this])
  (execute [this slack-msg args]))

このように扱うコマンドをプロトコルで抽象化し、refsetで受け取ることで基本機能側と拡張機能側の関係を疎にしている。

Slack RTM API Client

RTM API Clientは nijohando/eventの拡張でWebsocket経由で発生するRTM Eventsをイベント源としたEvent Busとして振る舞う。

前述のSlack Listenerから利用されているが、 拡張機能側にてSlackイベントを直接リスニングして何かをしたい場合はこのコンポーネントを利用することができる。

また今回はこのWebsocket処理のためにnijohando/event.websocketを作成。
Websocketの受信メッセージをイベント源とした nijohando/event の拡張でRTM API Clientが内部的に利用する。

このあたりは目的(3) core.asyncを使い倒す を過度に実践している感が否めないが、設計の良し悪しとは別にこのような試みを気兼ねなくできるところが趣味プロジェクトの良いところであったりする。

module.twitter

module.twitter拡張機能(Twitter連携)を実装したDuctモジュール。

Twitter command

jp.nijohando.chabonze.bot.command.Command プロトコルを実装したTiwtter連携拡張コマンドコンポーネント。 Bot宛に /twitter から始まるメッセージが送られた場合に実行される。

コマンドはさらに auth list watch といったサブコマンドを持ち、それぞれ認可、Twitterリストの表示、Slack上でのリスト・検索結果購読の機能を持つ。

コマンドコンポーネントは初期化時にコンフィグレーションマップ内の 複合キー [:jp.nijohando.chabonze/twitter :jp.nijohando.chabonze/subcommand]deriveしたコンポーネント群の参照を受け取りこれをサブコマンドとして認識する。

またこれらサブコマンドコンポーネントも jp.nijohando.chabonze.bot.command.Command プロトコルを実装するようにしTwitter commandコンポーネント側は同プロトコルを通じてサブコマンドを管理する。

(defn- command-config
  [command-name]
  ^:demote {:jp.nijohando.chabonze.twitter/command
            {:rtm (ig/ref :jp.nijohando.chabonze.bot.slack/rtm)
             :store (ig/ref :jp.nijohando.chabonze.bot/store)
             :command-name (merge/displace command-name)
             :subcommands (ig/refset [:jp.nijohando.chabonze/twitter :jp.nijohando.chabonze/subcommand])
             :logger (ig/ref :duct/logger)}})

Watch

jp.nijohando.chabonze.bot.command.Command プロトコルを実装したサブコマンドコンポーネント。

Watchコンポーネントはサブコマンドの実行処理に加えて、初期化時に購読設定に応じて定期的にリスト、検索結果の最新ツイートを取得するためのタイマータスクを起動する。
タイマータスクの処理のため nijohando/event.timer を作成。
これはnijohando/eventの拡張でcore.asyncのtimeoutによる時間経過検知をイベント源にする。WatchコンポーネントはこのBusから伝わる時間経過イベントに応じて最新ツイートの取得処理を行う。

chabonze-app

chabonze-appmodule.bot を組み込んだBotひな形アプリケーション。

以下のようにleiningenのduct用テンプレートで生成している。

lein new duct-beta chabonze-app

※ Ductの0.7.0のベータ版を使用しているので duct ではなく duct-beta

全ての機能がDuctモジュール側に寄せられているのでテンプレートから生成したプロジェクトほぼそのまま。

セットアップ

使用する拡張機能のdependencyとコンフィグレーションを追加する。

1. dependencyの追加

拡張機能のDuctモジュールをproject.cljのdependenciesに追加する。

  :dependencies [ ・・・ 
                  [jp.nijohando.chabonze/module.twitter "0.1.0"]]

2. コンフィグレーション追加

resources/chabonze_app/config.edn に拡張モジュール用のコンフィグレーションを追加。

{:duct.profile/base
 ・・・
 :jp.nijohando.chabonze.module/twitter {}
 ・・・}

アプリケーション実行

lein run

もしくはexecutable jar化して実行する場合は、

lein uberjar
java -jar target/chabonze.jar

またREPLからであれば、

lein repl
user=> (dev)
:loaded
dev=> (go)
:initiated

とアプリケーションを起動できる。

『ductにおけるREPLとmain実行時のコンポーネント初期化範囲の差異』 にも書いたようにテンプレートから作成した素のDuctアプリケーションではREPLとそれ以外でアプリケーション起動時のコンポーネントの初期化範囲に違いがあるため chabonze-app では以下のように :duct/daemon に加えて :jp.nijohando.chabonze/command:jp.nijohando.chabonze/subcommand を初期化対象としている。

(defn -main [& args]
  (let [keys     (or (duct/parse-keys args) [:duct/daemon
                                             :jp.nijohando.chabonze/command
                                             :jp.nijohando.chabonze/subcommand])
        profiles [:duct.profile/prod]]
    (-> (duct/resource "chabonze_app/config.edn")
        (duct/read-config)
        (duct/exec-config profiles keys))))

最後に

初めてのDuctアプリケーションが定番のWebアプリケーションではなくSlack Botという変則的な体験であったが、コンフィグレーションをベースに柔軟なコンポーネントの構築を行えるIntegrantとそれを補完しアプリケーションへの装着を容易にしてくれるDuctの組み合わせはとても強力でClojure サーバサイド開発における有力な選択肢であることを改めて認識した。

次はBoundariesを意識しつつDuctでWebアプリケーションを書いてみようと思う。