読者です 読者をやめる 読者になる 読者になる

八発白中

はてなブログに引越しました。

HubotスクリプトをCommon Lispで書く

f:id:nitro_idiot:20151225001557p:plain

いい加減ChatOpsにも手を付けたいなぁ、と思って、試しに家庭内SlackにHubotを導入してみました。

HubotはGitHub社製のチャットボットフレームワークです。CoffeeScriptで書かれていてNode.jsで動きます。挙動を追加するにはCoffeeScriptスクリプトを書きます。

これを利用して、チャットというインターフェイスを使って様々な日常タスクを処理させることができます。最近の流行りでは、hubot deployなどと唱えるとチャットからサーバのデプロイをしたりできるようです。

今年もChatOps Advent Calendarでチャットボットを使ったテクニックが投稿されているようです。

さて、導入したのはいいのですが、CoffeeScriptを書くのがどうにもダルいJavaScriptも受け付けるらしいけど、やっぱりさくっと書ける言語のほうがいい。つまりCommon Lispがいい。

かと言ってCommon Lispで一からボットフレームワーク作る労力は割けない。どうにかHubotを使ってできないかやってみることにしました。

ヘビーにRoswellに依存しているのでRoswellを知らない方はあらかじめこちらを参照してください。

サブプロセスでRoswellスクリプトを呼ぶ

まず考えたのが、Hubotスクリプトでサブプロセスを立ち上げてRoswellスクリプトを実行し、その結果をチャットに流す方法です。これならば主な実装をCommon Lispで書くことができます。

ディレクトリ構造
├ scripts/
│   Hubotスクリプト (.coffee, .js) 置き場
├ roswell/
│   Roswellスクリプト (.ros) 置き場
├ bin/
│   実行ファイル。hubotなど
├ Lakefile
├ README.md
├ external-scripts.json
├ hubot-scripts.json
└ package.json

以下のようなHubotスクリプトをscripts/whoami.coffeeに置いて、

module.exports = (robot) ->
  robot.respond /who are you\?/i, (msg) ->
    @exec = require('child_process').exec
    command = "sh #{ __dirname }/../roswell/whoami.ros"
    @exec command, (error, stdout, stderr) ->
      msg.send error if error?
      msg.send stdout if stdout?
      msg.send stderr if stderr?

そこから呼び出されるスクリプトをroswell/whoami.rosに書きます。

#|-*- mode:lisp -*-|#
#|
exec ros -Q -- $0 "$@"
|#

(defun main (&rest argv)
  (declare (ignorable argv))
  (format t "~&I'm fukabot!~%"))

こうすれば “who are you?” と声をかけると roswell/whoami.ros がサブプロセスで実行され、結果の “I’m fukabot!” が返ってきます。

f:id:nitro_idiot:20151224234827p:plain

前準備はサーバにRoswellをインストールすることだけです。手軽ですね。

全部Common Lispで書く

しかし、人間は欲が深い。

上記の方法では、新しくスクリプトを追加するときにCoffeeScriptとRoswellスクリプトの二つを追加しないといけません。これが何とも煩わしい。

特に、どの文言にマッチするかはCoffeeScriptに記述するのに、実装は別のファイルっていうのがあまりイケてない。全部Roswellスクリプトに書きたい。

そこで、少し変わったRoswellスクリプトを書くことで、そこからHubotスクリプトを生成する方式に変更しました。

拙作の hubotify.ros という変換用Roswellスクリプトを使います。これを実行権限をつけてbin/以下にでもダウンロードしておきます。

上述のRoswellスクリプト (roswell/whoami.ros) を以下のように追記します。

#|-*- mode:lisp -*-|#
#|
exec ros -Q -- $0 "$@"
|#

(defun main (&rest argv)
  (declare (ignorable argv))
  (format t "~&I'm fukabot!~%"))

(ql:quickload :parenscript :silent t)
(import '(ps:ps ps:@ ps:regex))

(defun js-main ()
  (ps
    ((@ robot respond) (regex "/who are you\\?/i")
     (lambda (msg)
       (run-main
        (lambda (error stdout stderr)
          (when error
            ((@ msg send) error))
          (when stdout
            ((@ msg send) stdout))
          (when stderr
            ((@ msg send) stderr))))))))

関数js-mainがあるのが特徴です。この関数はJavaScriptコードを文字列で返せば何でも良いです。JavaScriptコードを生成するのにはParenScriptを使っています。

関数run-mainは第一引数にcallback、以降の可変長引数としてRoswellスクリプトへの引数を受け取ります。

最後にこのRoswellスクリプトに対して hubotify.ros を実行します。

$ bin/hubotify.ros roswell/whoami.ros
Wrote '/Users/nitro_idiot/Programs/etc/fukabot/scripts/whoami.js'

これで scripts/whoami.js が生成されるので、そのままデプロイすれば大丈夫です。

ちなみに生成された whoami.js の中身はこのようになっています。

function runMain(callback) {
    var argv = [];
    for (var i1 = 0; i1 < arguments.length - 1; i1 += 1) {
        argv[i1] = arguments[i1 + 1];
    };
    this.execFile = require('child_process').execFile;
    __PS_MV_REG = {};
    return this.execFile(__dirname + '/../' + 'roswell/whoami.ros', argv, new(Object), function (error, stdout, stderr) {
        callback(error, stdout, stderr);
        __PS_MV_REG = {};
        return null;
    });
};
module.exports = function(robot) {
robot.respond(/who are you\?/i, function (msg) {
    return runMain(function (error, stdout, stderr) {
        if (error) {
            msg.send(error);
        };
        if (stdout) {
            msg.send(stdout);
        };
        return stderr ? msg.send(stderr) : null;
    });
});
};

新しくスクリプトを追加したいときはRoswellスクリプトを追加し、hubotifyすればHubotで使える形になります。

変換のときはLakeのタスクでlake hubotifyのように全てのRoswellスクリプトに対して実行できるようにすれば楽です。

;; Lakefile
(task "hubotify" ()
  (dolist (file (uiop:directory-files #P"roswell/"))
    (sh `("bin/hubotify" ,(namestring file)))))

まとめ

Roswell三昧でした。意外とやってやれないことはないものですね。

ParenScript部分が少し癖がありますが、そこは使っていくうちに共通化してもう少しこなれてくるんじゃないかと思います。