Common LispでSlack botを作る
チャットbotなるものにも(今さら)手を出してみようと、Slack用のサンプルbotをCommon Lispで書いてみました。
使い方
一応使い方です。
- 上記プロジェクトをquicklispからロード可能な場所にclone
settings.json.inを参考にsettings.json(下記参照)を作成- REPL上でサーバを立ち上げる。
(ql:quickload :sample-cl-bot)(clack:clackup sample-cl-bot:*app* :port 16111)
- SlackのOutgoing hookに
http://<アドレス>:16111/を登録
基本編:Slackとのやりとり

そもそもSlackとどうやりとりするのか、という話です。この手の解説は既に良いものが沢山あると思いますので、実装を簡単に見る程度にします。
流れだけ言いますと、Slack側ではOutgoing Hookにキーワード(上記の画像では"alien:")とLispサーバのアドレスを設定し、サーバ側ではSlackから受け取ったポストを元に適切なIncoming HookのURLへメッセージ(JSON形式)を投げ返すだけです。
サーバ部分は以下のような感じで、ningleを使ってルーティングしています。
(defvar *app* (make-instance 'ningle:<app>)) (setf (ningle:route *app* "/" :method :POST) #'(lambda (params) (aif (get-incoming-hook-url params) (dex:post it :content (parse-input (extract-posted-text params) params) :headers '(("content-type" . "application/json"))))))
get-incoming-hook-urlとparse-inputについて簡単に解説。
まず、get-incoming-hook-urlはその名の通り、投稿すべきIncoming Hookのアドレスを取り出す関数です。Outgoing Hookによるポストはtokenの情報を持っているので、下記の設定ファイルを基にこのtokenと宛先のIncoming Hookの対応をとります。
{ "pairs": [ { "token": "<XXX>" "incoming_hook": "https://hooks.slack.com/services/XXXX/XXXX/XXXX" }, { "token": "<YYY>" "incoming_hook": "https://hooks.slack.com/services/YYYY/YYYY/YYYY" } ] }
次に、parse-inputですが、これは受け取ったメッセージを解析して、適切なメッセージ(JSON形式)を作り出す関数(の入り口)です。内部では最終的に以下の関数を呼び出してJSONを構成します。
(defun make-post-content (text) (jonathan:to-json (list :|text| text :|icon_url| "http://www.lisperati.com/lisplogo_alien_128.png" :|username| "Lisp Alien")))
応用編:対話式のやりとり
このbotでは下記画像のように対話式のやりとりをサポートしています(画像ではrememberコマンドの後2つの応答。getコマンドは単なる結果確認用)。その実装に関する話です。

実現機構
肝になる部分は下記のように簡単な実装になっています(上記基本編で呼んでいたparse-input関数の定義がこれ)。なお、slet, itはanaphoraライブラリからインポートしたものです*1。
(defvar *continuity-table* (make-hash-table :test 'equalp)) (defun parse-input (text params) (slet (gethash (make-params-hash params) *continuity-table*) (multiple-value-bind (content continuity) (if it (funcall it text params) (parse-command text params)) (setf it continuity) content)))
Slackから受け取ったメッセージをパースしているのは、(if it (funcall it text params) (parse-command text params))の部分です。ifの分岐方向によらず下記のように動作します。
- 引数
text:受け取ったメッセージparams:その他Slackから受け取ったパラメータ一式*2
- 返り値
ポイントとなるのが第2返り値であるcontinuityです。この関数(またはNIL)は*continuity-table*に格納されます。このテーブルのキーとなっているのはユーザ識別情報です。したがって、同じユーザから再度メッセージが来た場合、下記のように動作します。
- テーブルにNILが格納されている → 標準のパーサである
parse-commandを呼び出す - テーブルに関数が格納されている → 対話用のパーサであるその関数を呼び出す(
(funcall it text params))
結局、普段はparse-commandで標準的な処理を行い、対話処理をしたい場合は後継の対話処理を登録しておき次回はそれを使う、というのが全体像です。
上記画像の例では下記のように対応します。
なお、聞きかじった程度の「継続」に近い気がしたので、continuityと名付けていますが、違うかもしれません…。
継続して利用する値の受け渡しについて
もう1点、"alien: the best game"とした時点で、ひとつ前の入力内容("斑鳩")がどこかに保存されている必要があります。これをどこに保存しているかという話ですが、クロージャに捕捉しています。
実例を見るのが早いかと思います。下記のmake-asking-value-fn関数がこの"alien: the best game"とした時点で呼ばれる関数を生成します。前段の"斑鳩"はkeyとして捕捉され、後で呼ばれる関数であるlambda以下から「見る」ことができます。
(defun make-asking-value-fn (key) (lambda (text params) (if (is-empty-string text) (values (make-post-to-mention ;; 「@<ユーザ名> ...」なポストを作る関数 (format nil "What is '~A'?" key) params) (make-asking-value-fn key)) (register-pair-and-make-post key text params))))
(一応)機能紹介
機能紹介…といっても、サンプル用に実装しただけなので、特に実用的な機能はありません。
remember/get/forget
多少実用的なものその1。

見てのとおりのkey, valueストアです。覚えた情報は基本的にtoken単位(≒チャンネル単位)で管理しているので、同じチャンネルであれば他の人が登録した情報もgetで見ることができます。
なお、永続化していないので環境を再起動すると消えます…*3。
rememberコマンドは「応用編」では対話式に覚えさせましたが、この画像のようにremember <key> = <value>で一発登録もできます。
画像にはありませんが、forget <key>で覚えた<key>の情報を削除します。
weather
多少実用的なものその2。

livedoorのお天気WebサービスのAPI*4を利用して、指定した地域の天気予報をとってきます。
なお、上記画像ではweatherのエイリアスであるwf("Weather Forecasts")を利用しています。

また、地味にrememberコマンドと連係しています。この画像のように、rememberコマンドで覚えたキー(上記のremember/getの例で覚えさせたもの)を地域名の代わりに利用できます。
その他
純粋なサンプル達
- hello:こんにちは

- echo:そのまま返します
- 余談ですが、下記画像のように、echoした情報にキーワード(
alien:)が含まれればコマンドとして解釈してしまいます- 必ずキーワードを消費するので無限ループはしない…はず
- 余談ですが、下記画像のように、echoした情報にキーワード(

- number game:対話式インタフェースのサンプルに作りました
- エイリアンの考えている番号を当てるだけのゲームです
- 勝率おおよそ1/7の○○ゲーです
