Common LispでSlack botを作る

チャットbotなるものにも(今さら)手を出してみようと、Slack用のサンプルbotCommon Lispで書いてみました。

github.com

使い方

一応使い方です。

  1. 上記プロジェクトをquicklispからロード可能な場所にclone
  2. settings.json.inを参考にsettings.json(下記参照)を作成
  3. REPL上でサーバを立ち上げる。
    1. (ql:quickload :sample-cl-bot)
    2. (clack:clackup sample-cl-bot:*app* :port 16111)
  4. SlackのOutgoing hookにhttp://<アドレス>:16111/を登録

基本編:Slackとのやりとり

f:id:eshamster:20160812001309p:plain

そもそも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-urlparse-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コマンドは単なる結果確認用)。その実装に関する話です。

f:id:eshamster:20160812000035p:plain

実現機構

肝になる部分は下記のように簡単な実装になっています(上記基本編で呼んでいたparse-input関数の定義がこれ)。なお、slet, itanaphoraライブラリからインポートしたものです*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
  • 返り値
    • 第1(content):Slackに投げるJSON
    • 第2(continuity):なし(= NIL)もしくは、textparamsを受け取り、content(とcontinuity)を返す関数

ポイントとなるのが第2返り値であるcontinuityです。この関数(またはNIL)は*continuity-table*に格納されます。このテーブルのキーとなっているのはユーザ識別情報です。したがって、同じユーザから再度メッセージが来た場合、下記のように動作します。

  1. テーブルにNILが格納されている → 標準のパーサであるparse-commandを呼び出す
  2. テーブルに関数が格納されている → 対話用のパーサであるその関数を呼び出す((funcall it text params)

結局、普段はparse-commandで標準的な処理を行い、対話処理をしたい場合は後継の対話処理を登録しておき次回はそれを使う、というのが全体像です。

上記画像の例では下記のように対応します。

  1. "alien: remember", "alien: get 斑鳩"(標準パーサ利用)
  2. "alien: 斑鳩", "alien: the best game"(対話用パーサ利用)

なお、聞きかじった程度の「継続」に近い気がしたので、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。

f:id:eshamster:20160814182852p:plain

見てのとおりのkey, valueストアです。覚えた情報は基本的にtoken単位(≒チャンネル単位)で管理しているので、同じチャンネルであれば他の人が登録した情報もgetで見ることができます。

なお、永続化していないので環境を再起動すると消えます…*3

rememberコマンドは「応用編」では対話式に覚えさせましたが、この画像のようにremember <key> = <value>で一発登録もできます。

画像にはありませんが、forget <key>で覚えた<key>の情報を削除します。

weather

多少実用的なものその2。

f:id:eshamster:20160814182902p:plain

livedoorお天気WebサービスAPI*4を利用して、指定した地域の天気予報をとってきます。

なお、上記画像ではweatherエイリアスであるwf("Weather Forecasts")を利用しています。

f:id:eshamster:20160814182909p:plain

また、地味にrememberコマンドと連係しています。この画像のように、rememberコマンドで覚えたキー(上記のremember/getの例で覚えさせたもの)を地域名の代わりに利用できます。

その他

純粋なサンプル達

  • hello:こんにちは

f:id:eshamster:20160814182944p:plain

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

f:id:eshamster:20160814183000p:plain

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

f:id:eshamster:20160814183015p:plain


*1:記事を書いていて気づきましたが、itの中身が全て定義で置き換えられることを考えると、恐ろしく無駄な処理をしていますね…。気が向いたら直します

*2:textはこのparamsから生成できますが、少し加工(キーワードと空白のトリミング)が必要で、かつメインの処理対象なので加工済みのものを渡しています

*3:一応、src/kv-storage.lispさえ対応すれば永続化対応できる作りです。面倒なのはテストですね…。

*4:登録不要で使えたのでこれにしました。