ClojureScriptでHubotスクリプトを書く

お正月、とある雑誌を読んで[Hubot](https://hubot.github.com)を試してみたくなりました。「ChatOpsでシステムの運用を自動化！」とか大げさなことは言わないまでも、今日の天気を教えてもらったり、おみくじを引いてみたり、ちょっとしたプログラムを書くだけでも楽しめそうですよね。

そこで早速いつも遊んでいるVPSにHubotを入れたわけですが、HubotはCoffeeScriptで書かれており、ユーザの書くスクリプトもCoffeeScript（もしくはJavaScript）で記述することとなります。これを期にCoffeeScriptを学ぶことも考えましたが、一方で最近はClojureに熱が入っていることもあり、JavaScriptで構わないならClojureScriptで書きたいです。もっと言うと、ClojureScriptで書くなら[Figwheel](https://github.com/bhauman/lein-figwheel)を使いたいです。ということでClojureScriptでHubotスクリプトを書いてみましたので、いくつか方法を共有します。


# 環境の準備
まずは通常どおりHubotのプロジェクトを作成します。[HubotのGetting Started](https://hubot.github.com/docs/)に従い、下記コマンドを実行します。プロジェクト名はcljs_hubotとしました。

```
% mkdir cljs_hubot
% cd cljs_hubot
% yo hubot
```

HerokuやRedisは使用しないので、Hubot起動時のエラーメッセージを抑止するために、`external-scripts.json`から`"hubot-heroku-keepalive"`の行と`"hubot-redis-brain"`の行を削除します。

この`cljs_hubot`ディレクトリを、同時にLeiningenプロジェクトのルートディレクトリとすることにし、ディレクトリ直下に以下の`project.clj`を作成します。`:target`に`:nodejs`を指定するのがブラウザ用のJavaScriptを生成するときと異なります[^*]。

[^*]: 参考：[Running ClojureScript on Node.js](https://github.com/clojure/clojurescript/wiki/Quick-Start#running-clojurescript-on-nodejs)

```clj:project.clj
(defproject cljs-hubot "0.1.0-SNAPSHOT"
  :description "FIXME: write this!"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}

  :dependencies [[org.clojure/clojure "1.7.0"]
                 [org.clojure/clojurescript "1.7.170"]]

  :plugins [[lein-cljsbuild "1.1.1"]
            [lein-figwheel "0.5.0-2"]]

  :clean-targets ^{:protect false} ["out" "scripts/cljs"]

  :cljsbuild {:builds [{:id "dev"
                        :source-paths ["src"]
                        :figwheel true
                        :compiler {:main cljs-hubot.core
                                   :asset-path "scripts/cljs/out"
                                   :output-to "scripts/cljs/cljs_hubot.js"
                                   :output-dir "scripts/cljs/out"
                                   :target :nodejs}}]}

  :figwheel { })
```

FigwheelはWebSocketを使って自動リロードを実現しているので、Node.js用のWebSocketライブラリをインストールします。（インストールしないとHubot起動時にワーニングが出ます。）

```
% npm install ws
```

そして、ClojureScriptのソースファイルを置くディレクトリを作成し、`lein deps`で必要なライブラリを取得しておきます。この時点で、`cljs_hubot`配下は以下のようになっています。

```
% mkdir -p src/cljs_hubot
% lein deps
Retrieving lein-figwheel/lein-figwheel/0.5.0-2/lein-figwheel-0.5.0-2.pom from clojars
・・・（中略）・・・
Retrieving figwheel/figwheel/0.5.0-2/figwheel-0.5.0-2.jar from clojars
```

```shell:cljs-hubot/
cljs_hubot/
├── Procfile
├── README.md
├── bin/
├── external-scripts.json
├── hubot-scripts.json
├── node_modules/
├── package.json
├── project.clj
├── scripts/
├── src/
└── target/
```

# スクリプトの作成（方法１．CoffeeScriptでマッチングしてからClojureScriptの関数を呼ぶ）
さて、どうやってClojureScriptをHubotスクリプトにするかですが、FigwheelのWiki [Node.js module development with figwheel](https://github.com/bhauman/lein-figwheel/wiki/Node.js-development-with-figwheel#nodejs-module-development-with-figwheel)によると、ClojureScriptの関数をモジュール化してJavaScriptから呼び出す方法があるようなので、これを利用します。

ClojureScriptのソースは以下のとおりです。今回は話しかけられたときに「Hello from ClojureScript!」と返事をするHubotスクリプトを作ります。

```clj:src/cljs_hubot/core.cljs
(ns ^:figwheel-always cljs-hubot.core
  (:require [cljs.nodejs :as nodejs]))

(nodejs/enable-util-print!)

(def -main (fn [] nil))
(set! *main-cli-fn* -main)

(defn hello [res]
  (.send res "Hello from ClojureScript!"))

(defn export-hello [res]
  (hello res))

(defonce init-stuff
  (set! (.-exports js/module) #js {:moduleHello export-hello}))
```

`(set! (.-exports js/module) #js {:moduleHello export-hello}))`の部分で`hello`関数をモジュール化しています。これによって他のJavaScriptから`moduleHello`関数を呼ぶことが可能になります。なお、このフォームはFigwheelによる自動リロードの度に評価されたとしても問題ないのですが、[Writing reloadable code](https://github.com/bhauman/lein-figwheel#writing-reloadable-code)の流儀（？）に従い、`defonce`で包んでいます。

ちなみに`export-hello`というエクスポート用の関数を、`hello`関数と外から呼ぶ`moduleHello`関数の間に挟んでいるのがミソで、この関数が間にいないとFigwheelの自動リロードは思うように動きません[^**]。

[^**]: これについては後述する「方法３」で解決します。

ClojureScriptの関数をモジュール化したところで、Hubotスクリプトからその関数を呼ぶようにします。

```coffeescript:script/cljs.coffee
# Description
#   Call ClojureScript function.

require "./cljs/cljs_hubot.js"
cljs = require "./cljs/out/cljs_hubot/core.js"

module.exports = (robot) ->
  robot.hear /hello/i, (res) ->
    cljs.moduleHello res # ClojureScriptの関数呼び出し
```
これでHubotスクリプトは完成です。


# 動作確認
実際にHubotを使って試してみます。まずはFigwheelのサーバを立ち上げます。

```
% rlwrap lein figwheel dev
Figwheel: Starting server at http://localhost:3449
Figwheel: Watching build - dev
Compiling "scripts/cljs/cljs_hubot.js" from ["src"]...
Successfully compiled "scripts/cljs/cljs_hubot.js" in 1.532 seconds.
Launching ClojureScript REPL for build: dev
Figwheel Controls:
          (stop-autobuild)                ;; stops Figwheel autobuilder
          (start-autobuild [id ...])      ;; starts autobuilder focused on optional ids
          (switch-to-build id ...)        ;; switches autobuilder to different build
          (reset-autobuild)               ;; stops, cleans, and starts autobuilder
          (reload-config)                 ;; reloads build config and resets autobuild
          (build-once [id ...])           ;; builds source one time
          (clean-builds [id ..])          ;; deletes compiled cljs target files
          (print-config [id ...])         ;; prints out build configurations
          (fig-status)                    ;; displays current state of system
  Switch REPL build focus:
          :cljs/quit                      ;; allows you to switch REPL to another build
    Docs: (doc function-name-here)
    Exit: Control+C or :cljs/quit
 Results: Stored in vars *1, *2, *3, *e holds last exception object
Prompt will show when Figwheel connects to your application
To quit, type: :cljs/quit
```

Figwheelサーバの起動を確認したら、別のコンソールを使ってHubotを起動します。

```
% bin/hubot
cljs-hubot> Figwheel: trying to open cljs reload socket
Figwheel: socket connection established
```

Hubotのコンソールに`Figwheel: socket connection established`というメッセージが表示され、Figwheelのコンソールに`cljs.user=>`というプロンプトが返ってきていたら無事接続できています。

Hubotのプロンプトを使って話しかけると、ちゃんと返事してくれました。

```
cljs-hubot> hello
cljs-hubot> Hello from ClojureScript!
```

Figwheelの自動リロードも確認します。試しに`core.cljs`のhello関数の文字列を`"Hello from Cljs!"`と書き換えて保存すると、Hubot側のプロンプトに以下のようなメッセージが表示され、話しかけたときの返事も変わると思います。

```
Figwheel: notified of file changes
Figwheel: loaded these dependencies
("../D07BFF3.js")
Figwheel: loaded these files
("../cljs_hubot/core.js")

cljs-hubot> hello
cljs-hubot> Hello from Cljs!

```

# スクリプトの作成（方法２．マッチングもClojureScriptで行う）
上記の方法は比較的シンプルですが、Hubotが聞いた言葉とのマッチング部分がCoffeeScript部分に残っています。そのため、例えば新しい言葉に反応したくなった場合などは都度`cljs.coffee`を書き換えてHubotを再起動する必要があります。Figwheelの自動リロード機能を活かせておらず、ちょっともったいない気がします。

そこで、多少強引ですが`cljs.coffee`では聞きとったメッセージに対するハンドリングは一切行わないようにし、マッチング処理は全て`core.cljs`に任せることとします。具体的には以下のとおりです。

```coffeescript:script/cljs.coffee
# Description
#   Always Call ClojureScript handler.

require "./cljs/cljs_hubot.js"
cljs = require "./cljs/out/cljs_hubot/core.js"

module.exports = (robot) ->
  robot.hear /.*/i, (res) -> # 全ての文言にマッチ
    cljs.handler res
```
```clj:src/cljs_hubot/core.cljs
(ns ^:figwheel-always cljs-hubot.core
  (:require [cljs.nodejs :as nodejs]))

(nodejs/enable-util-print!)

(def -main (fn [] nil))
(set! *main-cli-fn* -main)

(defn hello [res]
  (.send res "Hello from CljureScript!"))

(def routes {"hello" hello})

(defn handler [res]
  (let [msg-text (.toString (.-message res))
        callback-fn (routes msg-text)]
    (if callback-fn (callback-fn res))))

(defonce init-stuff
  (set! (.-exports js/module) #js {:handler handler}))
```

`cljs.coffee`では、何かしらのメッセージを受け取ったら、とにかく全て`core.cljs`の`handler`関数に投げてしまいます。一方で、`core.cljs`の`handler`関数で束縛している`msg-text`にはHubotが聞いた文字列が入っているので、これを`routes`のキーと比較し、一致したキーの値となる関数を評価するようにしています。

新しい言葉に反応したい場合は、`routes`に対して、その言葉と、対応するコールバック関数を追加することで対応できます。`routes`を変更するだけなら（`handler`関数の変更を伴わないものであるなら）、Figwheelの自動リロードが働くのでHubotの再起動は不要です。


# スクリプトの作成（方法３．ClojureScriptでRobotインスタンスを操作する）
上記「方法２」はHubotが提供している`Robot#hear`や`Robot#respond`などのメソッドが使えなくなる代わりに、Clojureの純粋なデータ構造によるマッチングが可能です。当初考えていた程度のHubotスクリプトであれば既に十分実現できるものの、ここまで来たらCoffeeScriptのコードをさらに短くしたくなりますよね。

なんだか当初の目的を若干見失いつつありますが、以下に示すとおり、`Robot`インスタンスの状態を適切に管理すれば、ほぼ全ての処理をClojureScriptに委譲しつつ、Figwheelで快適に開発できます。

```coffeescript:script/cljs.coffee
# Description
#   Delegate scripts to ClojureScript.

require "./cljs/cljs_hubot.js"
module.exports = require("./cljs/out/cljs_hubot/core.js").init
```

```clj:src/cljs_hubot/core.cljs
(ns ^:figwheel-always cljs-hubot.core
  (:require [cljs.nodejs :as nodejs]))

(nodejs/enable-util-print!)

(def -main (fn [] nil))
(set! *main-cli-fn* -main)

(defonce robot (atom nil))

(defn hello [res]
  (.send res "Hello from ClojureScript!"))

(defn setup-listeners []
  (.hear @robot #"hello" "cljs" hello))

(defn teardown-listeners []
  (let [all-lsnrs (.-listeners @robot)
        non-cljs-lsnrs (remove #(= (.-options %) "cljs") all-lsnrs)]
    (set! (.-listeners @robot) (clj->js non-cljs-lsnrs))))

(defn init [initial-robot]
  (reset! robot initial-robot)
  (setup-listeners))

(defonce init-stuff
  (set! (.-exports js/module) #js {:init init}))

(defn fig-reload-hook []
  (teardown-listeners)
  (setup-listeners))
```
`init`関数はHubotが`cljs.coffee`を読み込む処理（`Robot#loadFile`）で呼ばれます。`Robot#loadFile`のソースを見るとわかるのですが、その際にRobotインスタンスは自分自身を引数として渡してくるので、ClojureScript側で`initial-robot`として受け取り、変数`robot`に束縛することができます。続けて、`init`関数では`setup-listeners`関数を使って`Robot#hear`を呼び、CoffeeScriptのときと同じ要領でコールバック関数を登録します。

Figwheelで開発するときは[reloadable code](https://github.com/bhauman/lein-figwheel#writing-reloadable-code)となることを意識します。リンク先では`button`クラスに対してリスナーを登録する際、リロードの度に`off`メソッドと`on`メソッドを使ってリスナーを解除する例が示されています。同様に、Hubotでも`Robot#hear`で登録したリスナーを解除すれば良いわけですが、残念ながら`Robot`クラスには`off`メソッドに対応するメソッドが用意されていないので、自力でオブジェクトを操作して解除する必要があります。

幸い、`Robot#hear`メソッドにはオプションとしてIDを登録できる引数があったので、今回はこれを利用し、ClojureScriptで登録したリスナーには`"cljs"`というIDを付けることにしました。`teardown-listeners`関数ではこのIDを目印にして、Robotインスタンスのリスナーをリセットしています。

最後に、Figwheelの自動リロードで呼び出す関数を、`project.clj`の`:on-jsload`で指定します。これで、`Robot#hear`や`Robot#respond`もClojureScriptの中で自由に追加、修正できるようになりました。

```clj:project.clj
(defproject cljs-hubot "0.1.0-SNAPSHOT"
  :description "FIXME: write this!"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}

  :dependencies [[org.clojure/clojure "1.7.0"]
                 [org.clojure/clojurescript "1.7.170"]]

  :plugins [[lein-cljsbuild "1.1.1"]
            [lein-figwheel "0.5.0-2"]]

  :clean-targets ^{:protect false} ["out" "scripts/cljs"]

  :cljsbuild {:builds [{:id "dev"
                        :source-paths ["src"]
                        :figwheel {:on-jsload "cljs-hubot.core/fig-reload-hook"} ; 変更箇所
                        :compiler {:main cljs-hubot.core
                                   :asset-path "scripts/cljs/out"
                                   :output-to "scripts/cljs/cljs_hubot.js"
                                   :output-dir "scripts/cljs/out"
                                   :target :nodejs}}]}

  :figwheel { })
```

# まとめ
以上、ClojureScriptでHubotスクリプトを書く方法でした。Hubotの処理を追いかけるため、なんだかんだHubotのソースコードを読むことになりましたが、CoffeeScriptが読みやすいのか、Hubotのソースが綺麗なのか、なんとなく理解する程度であればどうにかなりました。

また、ここには書きませんでしたがClojureには[nREPL](https://github.com/clojure/tools.nrepl)というインターフェースがあるので、方法２までであれば、HubotとClojureのプロセス間通信でもほぼ同様のことが実現できます。（この場合は、[Node.jsのnREPLクライアントライブラリ](https://github.com/rksm/node-nrepl-client)を使います。今考えるとこれが一番簡単だったかもです。Figwheelに頼らなくてもエディタから簡単にリロード（再評価）できるし…。）

いずれにせよ、HubotスクリプトはClojurianな方々でも実装できますので、この記事が「Hubotで遊びたいけどCoffeeScriptは面倒くさい…Clojureならスラスラ書けるのに…！」というニッチなニーズをお持ちの方に対して、少しでも参考になれば幸いです。