お正月、とある雑誌を読んでHubotを試してみたくなりました。「ChatOpsでシステムの運用を自動化!」とか大げさなことは言わないまでも、今日の天気を教えてもらったり、おみくじを引いてみたり、ちょっとしたプログラムを書くだけでも楽しめそうですよね。
そこで早速いつも遊んでいるVPSにHubotを入れたわけですが、HubotはCoffeeScriptで書かれており、ユーザの書くスクリプトもCoffeeScript(もしくはJavaScript)で記述することとなります。これを期にCoffeeScriptを学ぶことも考えましたが、一方で最近はClojureに熱が入っていることもあり、JavaScriptで構わないならClojureScriptで書きたいです。もっと言うと、ClojureScriptで書くならFigwheelを使いたいです。ということでClojureScriptでHubotスクリプトを書いてみましたので、いくつか方法を共有します。
環境の準備
まずは通常どおりHubotのプロジェクトを作成します。HubotのGetting Startedに従い、下記コマンドを実行します。プロジェクト名は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を生成するときと異なります1。
(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
cljs_hubot/
├── Procfile
├── README.md
├── bin/
├── external-scripts.json
├── hubot-scripts.json
├── node_modules/
├── package.json
├── project.clj
├── scripts/
├── src/
└── target/
スクリプトの作成(方法1.CoffeeScriptでマッチングしてからClojureScriptの関数を呼ぶ)
さて、どうやってClojureScriptをHubotスクリプトにするかですが、FigwheelのWiki Node.js module development with figwheelによると、ClojureScriptの関数をモジュール化してJavaScriptから呼び出す方法があるようなので、これを利用します。
ClojureScriptのソースは以下のとおりです。今回は話しかけられたときに「Hello from ClojureScript!」と返事をするHubotスクリプトを作ります。
(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の流儀(?)に従い、defonce
で包んでいます。
ちなみにexport-hello
というエクスポート用の関数を、hello
関数と外から呼ぶmoduleHello
関数の間に挟んでいるのがミソで、この関数が間にいないとFigwheelの自動リロードは思うように動きません2。
ClojureScriptの関数をモジュール化したところで、Hubotスクリプトからその関数を呼ぶようにします。
# 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!
スクリプトの作成(方法2.マッチングもClojureScriptで行う)
上記の方法は比較的シンプルですが、Hubotが聞いた言葉とのマッチング部分がCoffeeScript部分に残っています。そのため、例えば新しい言葉に反応したくなった場合などは都度cljs.coffee
を書き換えてHubotを再起動する必要があります。Figwheelの自動リロード機能を活かせておらず、ちょっともったいない気がします。
そこで、多少強引ですがcljs.coffee
では聞きとったメッセージに対するハンドリングは一切行わないようにし、マッチング処理は全てcore.cljs
に任せることとします。具体的には以下のとおりです。
# 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
(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の再起動は不要です。
スクリプトの作成(方法3.ClojureScriptでRobotインスタンスを操作する)
上記「方法2」はHubotが提供しているRobot#hear
やRobot#respond
などのメソッドが使えなくなる代わりに、Clojureの純粋なデータ構造によるマッチングが可能です。当初考えていた程度のHubotスクリプトであれば既に十分実現できるものの、ここまで来たらCoffeeScriptのコードをさらに短くしたくなりますよね。
なんだか当初の目的を若干見失いつつありますが、以下に示すとおり、Robot
インスタンスの状態を適切に管理すれば、ほぼ全ての処理をClojureScriptに委譲しつつ、Figwheelで快適に開発できます。
# Description
# Delegate scripts to ClojureScript.
require "./cljs/cljs_hubot.js"
module.exports = require("./cljs/out/cljs_hubot/core.js").init
(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となることを意識します。リンク先では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の中で自由に追加、修正できるようになりました。
(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というインターフェースがあるので、方法2までであれば、HubotとClojureのプロセス間通信でもほぼ同様のことが実現できます。(この場合は、Node.jsのnREPLクライアントライブラリを使います。今考えるとこれが一番簡単だったかもです。Figwheelに頼らなくてもエディタから簡単にリロード(再評価)できるし…。)
いずれにせよ、HubotスクリプトはClojurianな方々でも実装できますので、この記事が「Hubotで遊びたいけどCoffeeScriptは面倒くさい…Clojureならスラスラ書けるのに…!」というニッチなニーズをお持ちの方に対して、少しでも参考になれば幸いです。
-
これについては後述する「方法3」で解決します。 ↩