LoginSignup
11
10

More than 5 years have passed since last update.

ClojureScriptでHubotスクリプトを書く

Posted at

お正月、とある雑誌を読んで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

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
cljs-hubot/
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スクリプトを作ります。

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の流儀(?)に従い、defonceで包んでいます。

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

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

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!

スクリプトの作成(方法2.マッチングもClojureScriptで行う)

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

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

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
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.cljshandler関数に投げてしまいます。一方で、core.cljshandler関数で束縛しているmsg-textにはHubotが聞いた文字列が入っているので、これをroutesのキーと比較し、一致したキーの値となる関数を評価するようにしています。

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

スクリプトの作成(方法3.ClojureScriptでRobotインスタンスを操作する)

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

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

script/cljs.coffee
# Description
#   Delegate scripts to ClojureScript.

require "./cljs/cljs_hubot.js"
module.exports = require("./cljs/out/cljs_hubot/core.js").init
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となることを意識します。リンク先ではbuttonクラスに対してリスナーを登録する際、リロードの度にoffメソッドとonメソッドを使ってリスナーを解除する例が示されています。同様に、HubotでもRobot#hearで登録したリスナーを解除すれば良いわけですが、残念ながらRobotクラスにはoffメソッドに対応するメソッドが用意されていないので、自力でオブジェクトを操作して解除する必要があります。

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

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

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というインターフェースがあるので、方法2までであれば、HubotとClojureのプロセス間通信でもほぼ同様のことが実現できます。(この場合は、Node.jsのnREPLクライアントライブラリを使います。今考えるとこれが一番簡単だったかもです。Figwheelに頼らなくてもエディタから簡単にリロード(再評価)できるし…。)

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


  1. 参考:Running ClojureScript on Node.js 

  2. これについては後述する「方法3」で解決します。 

11
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
10