概要
チャットボットという言葉が流行語じみてきた昨今。
来たるLINE BOT AWARDSにClojureで参戦したいという方々も当然いらっしゃることかと存じあげます。
しかし公式のLINE Messaging API SDKには、何故だかClojureのものがありません。
かと言ってJavaのSDKをClojureから呼ぶのは負けた気がします。
やることと言えばHTTP + JSONの通信だけなので、難しいことはないでしょうし。
というわけで、JavaのSDKを使わずにClojureでLINE BOTの実装を進めていき、最終的に以下のライブラリにしましたので、その過程をこちらの記事でご紹介させていただきます。
正直まだまだ機能不足なところはありますが…生暖かくご鑑賞下さい。
Pull Reqはもちろん、「こうした方がイケてる」「ここがバグってる」等のご指摘も大歓迎です。
まずオウム返しBOTのWebサービスを作ってみる
一番最初は、ライブラリ化はせず最も簡素な形でLINE BOTとなるWebサービスを作るところから始めてみました。
最終的なコードはこちらに上がっていまして、リファクタの段階に応じてtagを打っています。
このセクションではまずこの状態にしていきます。
インフラ環境
まずは動けば良いということで、LINE BOT をとりあえずタダで Heroku で動かすの構成で始めました。
なお、こちらのような非同期な仕組みには今回は言及しません。
最初はAWSにてLambda + API Gatewayで動かす予定でしたが、IP固定が必要な件を知り面倒になって撤退したという。
Webアプリケーションの土台を作る
Heroku上で動作するプロジェクトの作成
とりあえず安定のCompojureです。
(しかし後になって気づいたんですが、ルーティングが実質1つだけなので素のRingでもあまり問題なかったような…。まあ監視用URL等の追加を見越している、ということで…)
$ lein new compojure clojure-line-bot
Herokuで動作するようにします。
Heroku本家のclojure-getting-startedと比較して不足している部分を適当に入れます。
ちなみにそちらのソースコードに含まれているcompojure.handler.site
はDEPRECATEDだそうです。
https://weavejester.github.io/compojure/compojure.handler.html
git diffでの差分を以下に載せておきます。
diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..5e8fbb4
--- /dev/null
+++ b/Procfile
@@ -0,0 +1 @@
+web: java $JVM_OPTS -cp target/clojure-line-bot-standalone.jar clojure.main -m clojure-line-bot.handler
\ No newline at end of file
diff --git a/project.clj b/project.clj
index 1d58561..f1d9dba 100644
--- a/project.clj
+++ b/project.clj
@@ -4,9 +4,13 @@
:min-lein-version "2.0.0"
:dependencies [[org.clojure/clojure "1.8.0"]
[compojure "1.5.1"]
- [ring/ring-defaults "0.2.1"]]
- :plugins [[lein-ring "0.9.7"]]
+ [ring/ring-defaults "0.2.1"]
+ [ring/ring-jetty-adapter "1.5.0"]
+ [environ "1.1.0"]]
+ :plugins [[lein-ring "0.9.7"]
+ [lein-environ "1.1.0"]]
:ring {:handler clojure-line-bot.handler/app}
+ :uberjar-name "clojure-line-bot-standalone.jar"
:profiles
{:dev {:dependencies [[javax.servlet/servlet-api "2.5"]
[ring/ring-mock "0.3.0"]]}})
diff --git a/src/clojure_line_bot/handler.clj b/src/clojure_line_bot/handler.clj
index 276c95c..2e4dce6 100644
--- a/src/clojure_line_bot/handler.clj
+++ b/src/clojure_line_bot/handler.clj
@@ -1,7 +1,9 @@
(ns clojure-line-bot.handler
(:require [compojure.core :refer :all]
[compojure.route :as route]
- [ring.middleware.defaults :refer [wrap-defaults site-defaults]]))
+ [environ.core :refer [env]]
+ [ring.middleware.defaults :refer [wrap-defaults site-defaults]]
+ [ring.adapter.jetty :as jetty]))
(defroutes app-routes
(GET "/" [] "Hello World")
@@ -9,3 +11,7 @@
(def app
(wrap-defaults app-routes site-defaults))
+
+(defn -main [& [port]]
+ (let [port (Integer. (or port (env :port) 5000))]
+ (jetty/run-jetty app {:port port :join? false})))
Webhook URLを設ける
LINEにメッセージを送信したときにLINEから呼び出される、こちらのWeb APIのエンドポイントが必要となります。
パスは/linebot/callback
としましょう。
この段階ではPOSTされたリクエストボディをそのまま表示する仕様にしておきます。
変更内容は以下となります。
diff --git a/src/clojure_line_bot/handler.clj b/src/clojure_line_bot/handler.clj
index 2e4dce6..fc1c2f3 100644
--- a/src/clojure_line_bot/handler.clj
+++ b/src/clojure_line_bot/handler.clj
@@ -2,15 +2,17 @@
(:require [compojure.core :refer :all]
[compojure.route :as route]
[environ.core :refer [env]]
- [ring.middleware.defaults :refer [wrap-defaults site-defaults]]
+ [ring.middleware.defaults :refer [wrap-defaults api-defaults]]
[ring.adapter.jetty :as jetty]))
(defroutes app-routes
- (GET "/" [] "Hello World")
+ (POST "/linebot/callback" {body :body} body)
(route/not-found "Not Found"))
(def app
- (wrap-defaults app-routes site-defaults))
+ (wrap-defaults app-routes (assoc-in api-defaults
+ [:params :urlencoded] false)))
(defn -main [& [port]]
(let [port (Integer. (or port (env :port) 5000))]
defroutes
にルーティングを追加しているのに加え、wrap-defaults
の第二引数をsite-defaults
からapi-defaults
に変更しています。
site-defaults
の場合、その名の通りWeb APIではなくWebサイトを想定しているため、CSRF対策でPOSTリクエストがエラーになったり、Cookie等の余分なものを含んでいるためです。
中身はこちらです。
参考: CompojureのCSRF対策トークンのanti-forgery-token作成
今回はWeb APIとなりますので、api-defaults
を選びました。
それに加え、上記の通り:urlencoded
をfalse
にしています。この設定がない場合、curl
の-d
オプションで指定したリクエストボディが空になるためです(原因は未調査)。
ではここで一度動かしてみます。
まずはローカルでサーバを起動します。
$ lein ring server-headless
(もちろんREPLから(start-server)
的なことをしても構いません)
続いてリクエストを送ります。
$ curl http://localhost:3000/linebot/callback -X POST -d 'hogehoge'
hogehoge
成功しました。
これでLINE BOT以前の部分が完成となります。
リクエストボディから必要なデータを抽出する
LINE Messaging APIのドキュメントによると、テキストでのメッセージ送信以外にもいくつかのイベント情報がPOSTされることが分かります。
今回はオウム返しを作ろうとしていますので、その中からテキストでのメッセージ送信イベントのみを抽出し、その中から送信されたテキストを取得することになります。
さて、リクエストボディはJSON形式ですのでパースのためにCheshireを導入します。
Ring-JSONの利用も考えたのですが、後にこのPOSTリクエストを検証処理を含める際に、リクエスト時そのままのテキストデータが必要となるため今回は見送りました。
また、この時点でロギングのライブラリであるTimbreも導入します。
LINEにてボットから応答を返すためには次項で説明するLINE側のAPIコールをこちらのアプリケーション内で行う必要があるのですが、そちらを作るまでは正常に動作をしているかの確認をログにて行いたいからです。
というわけでproject.cljに両者を加え、hander.cljでよしなにrequireします。
そしてLINEに送信されたメッセージ本文をログ出力するコードは以下のような感じになりました。
(POST "/linebot/callback" {body :body}
(->> (parse-string (slurp body) true)
:events
(filter #(and
(= (:type %) "message")
(= (get-in % [:message :type]) "text")))
(map #(info (get-in % [:message :text])))))
ローカルでの動作確認は以下のようにcurl
してログを確認すればよいでしょう。
curl http://localhost:3000/linebot/callback -X POST -d '
{
"events": [
{
"replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
"type": "message",
"timestamp": 1462629479859,
"source": {
"type": "user",
"userId": "U206d25c2ea6bd87c17655609a1c37cb8"
},
"message": {
"id": "325708",
"type": "text",
"text": "Hello, world"
}
},
{
"replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
"type": "follow",
"timestamp": 1462629479859,
"source": {
"type": "user",
"userId": "U206d25c2ea6bd87c17655609a1c37cb8"
}
}
]
}
'
次いでHerokuにデプロイし、LINEからリクエストが飛ぶように設定し、LINEにメッセージを投げてみます。
が、この仮定は色々な方が解説済みでしょうから割愛します。
結果はheroku logs
での確認となります。
LINEから返事が来るようにする
BOTから返事をさせるには、Reply Message APIを叩きます。
そのHTTPコールをするにあたりclj-httpを導入しました。
なお、LINEのAPIをコールをするためのトークンはハードコーディングしたくないので環境変数を使います。
最初にHeroku対応させた時にEnvironを本家に倣って導入していたので、活用します。
以下が完成したオウム返しLINE BOTのClojure実装となります。
(ns clojure-line-bot.handler
(:require [compojure.core :refer :all]
[compojure.route :as route]
[environ.core :refer [env]]
[cheshire.core :refer [parse-string generate-string]]
[clj-http.client :as http-client]
[taoensso.timbre :as timbre :refer [error]]
[ring.middleware.defaults :refer [wrap-defaults api-defaults]]
[ring.adapter.jetty :as jetty]))
(def line-channel-token (env :line-channel-token))
(def line-api-endpoint "https://api.line.me/v2/bot")
(def line-api-reply-path "/message/reply")
(defn reply [to-user-id reply-token message]
(let [body (generate-string {:to to-user-id
:replyToken reply-token
:messages [{:type "text"
:text message}]})]
(try
(http-client/post (str line-api-endpoint line-api-reply-path)
{:body body
:headers {"Authorization" (str "Bearer " line-channel-token)}
:content-type :json})
(catch Exception e
(let [exception-data (.getData e)
status (:status exception-data)
message (:body exception-data)]
(error (format "status %d. %s" status message)))))))
(defroutes app-routes
(POST "/linebot/callback" {body :body}
(->> (parse-string (slurp body) true)
:events
(filter #(and
(= (:type %) "message")
(= (get-in % [:message :type]) "text")))
;; mapの結果をそのまま返しているのが行儀悪い気もしますが
;; status code 200が返ればOKなようなのでいったんこのまま
(map #(reply (get-in % [:source :userId])
(:replyToken %)
(get-in % [:message :text])))))
(route/not-found "Not Found"))
(def app
(wrap-defaults app-routes (assoc-in api-defaults
[:params :urlencoded] false)))
(defn -main [& [port]]
(let [port (Integer. (or port (env :port) 5000))]
(jetty/run-jetty app {:port port :join? false})))
ごく当たり前の話ですが、このAPIコールが失敗した場合に備えて、エラーメッセージのログ出力はするようにしておきます。
これがないとLINEのAPIからエラーが返ってきた時によく分からなくなるので。
ちなみにmap
中でreply
関数呼び出しによるHTTPコールをしているのでレスポンスまでに直列にN回I/Oが発生するわけですが、ここではいったん良しとしています。
リクエストの検証
さて、上記のBOTはデンジャーです。リクエストの検証を行なっていないためです。
公式ドキュメントに以下の説明があります。
Signature Valdation
リクエストの送信元がLINEであることを確認するために署名検証を行わなくてはなりません。
各リクエストには X-Line-Signature ヘッダが付与されています。
X-Line-Signature ヘッダの値と、Request Body と Channel Secret から計算した Signature が同じものであることをリクエストごとに必ず検証してください。検証は以下の手順で行います。
- Channel Secretを秘密鍵として、HMAC-SHA256アルゴリズムによりRequest Bodyのダイジェスト値を得る。
- ダイジェスト値をBASE64エンコードした文字列が、Request Headerに付与されたSignatureと一致することを確認する。
というわけでリクエストの検証処理を作っていきます。
まずはテストを書く
Clojureに限らず、この手のものは先にテストを書いた方が個人的に楽だと思っています。
REPL実行して得られたダイジェスト値を眺めても、それが正しい値か自分には分からないので…。
ひとまずは空実装をします。
(defn validate-signature [content signature]
true)
次にlein new
した時に作られたテストコードを書き換えます。
こちらからテストデータをいただきました。
(deftest test-validate-signature
(let [signature "3q8QXTAGaey18yL8FWTqdVlbMr6hcuNvM4tefa0o9nA="
content "{}"]
(is (true? (validate-signature content signature))))
(let [signature "596359635963"
content "{}"]
(is (false? (validate-signature content signature)))))
Secretについては、せっかくEnvironを使っているのでproject.cljで定義します。
試しにlein test
を打ち、テストを通ることが確認できたら準備完了です。
蛇足ですが、project.cljのprofiles
の中でテスト用にEnvirionの環境変数設定をしたら、CIDERのテスト実行C-c C-t C-n
がその値を読んでくれなかったです。未解決事件。
実装
以下、前項のテストコードを実行しつつ行ないました。
まずはダイジェスト値の計算にあたりpandectを導入します。
それ以外は…特筆するところはないです。
実装はこのようになりました。
(def line-channel-secret (env :line-channel-secret))
(defn validate-signature [content signature]
(let [hash (sha256-hmac-bytes content line-channel-secret)
decoded-signature (.. java.util.Base64 getDecoder (decode signature))]
(. java.security.MessageDigest isEqual hash decoded-signature)))
(defroutes app-routes
(POST "/linebot/callback" {body :body headers :headers}
(let [content (slurp body)]
(if (validate-signature content (get headers "x-line-signature"))
(
;; validだったらオウム返しこれまで通りのレスポンスを返す
)
;; invalidだったら400
{:status 400
:headers {}
:body "bad request"}))))
ちなみにBase64
とMessageDigest
はimport
して名前空間を付与しない方がそれっぽい気がします。
記述を綺麗に
さてこれでオウム返しには成功したわけですが、ここで終わっては正直微妙なのでもう少し続けます。
LINEから送信されたイベントをさばく処理を再び見てみましょう。
(->> (parse-string content true)
:events
(filter #(and
(= (:type %) "message")
(= (get-in % [:message :type]) "text")))
(map #(reply (get-in % [:source :userId])
(:replyToken %)
(get-in % [:message :text]))))
シーケンスから対象のブツを探して取得しているわけですが、イベントの種類にはMessage以外にもFollowなど複数あり、またMessageイベントについてはTextの他にImageなど複数種類あります。
今回はTextのMessageしか対応していないものの、各イベントに対する振舞いを定義するのは、上記のままでは泥臭さがあります。
では、こんな風にマップでイベントハンドラを定義してみるのはどうでしょうか。
(def line-events
{"message" {"text" #(reply (get-in % [:source :userId])
(:replyToken %)
(get-in % [:message :text]))
:else #(info (str "messageだけどtext以外が来たよ" %))}
:else #(info (str "message以外が来たよ" %))})
このline-events
を処理するということで、(相変わらず)LINEへのレスポンスをどうするかとエラーハンドリングを忘れて進めると、以下のような形になります。
(defn route-line-events [events]
(map (fn [event]
(let [ev-type (:type event)]
(if-let [handler (get line-events ev-type)]
(if (or (not= ev-type "message") (fn? handler))
(handler event)
(let [sub-type (get-in event [:message :type])
sub-events handler]
(if-let [sub-handler (get sub-events sub-type)]
(sub-handler event)
((:else sub-events) event))))
((:else line-events) event))))
events))
(defroutes app-routes
(POST "/linebot/callback" {body :body headers :headers}
(let [content (slurp body)]
(if (validate-signature content (get headers "x-line-signature"))
(->> (parse-string content true)
:events
route-line-events)
{:status 400
:headers {}
:body "bad request"}))))
でも、折角ですからCompojureのdefroutes
みたいに書けるようにしてみましょう。
;; こう書きたい
(deflineevents app-lineevents
(MESSAGE [event]
(TEXT [event]
(reply (get-in event [:source :userId])
(:replyToken event)
(get-in event [:message :text])))
(ELSE [event]
(info (str "messageだけどtext以外が来たよ" event))))
(ELSE [event]
(info (str "message以外が来たよ" event))))
ちなみにですが、マクロ・クラブの最初のルールは「マクロを書くな」だそうです。
閑話休題。
最初はシンプルにこの形を作ることを目指します。
(deflineevents app-lineevents
(MESSAGE [event]
(reply (get-in event [:source :userId])
(:replyToken event)
(get-in event [:message :text])))
(ELSE [event]
(info (str "message以外が来たよ" event))))
そして生まれたのがこちらです。
(defn compile-event [name args handler]
(let [arg (first args)]
{name `(fn[~arg] ~handler)}))
(defmacro MESSAGE [arg handler]
(compile-event "message" arg handler))
(defmacro ELSE [arg handler]
(compile-event :else arg handler))
(defmacro deflineevents [name & forms]
`(defn ~name [events#]
(let [handler-map# (apply merge ~@forms)]
(map (fn [event#]
(let [ev-type# (:type event#)]
(if-let [handler# (get handler-map# ev-type#)]
(if (or (not= ev-type# "message") (fn? handler#))
(handler# event#)
(let [sub-type# (get-in event# [:message :type])
sub-events# handler#]
(if-let [sub-handler# (get sub-events# sub-type#)]
(sub-handler# event#)
((:else sub-events#) event#))))
((:else handler-map#) event#))))
events#))))
ライブラリ化
「記述を綺麗に」というコンセプトでマクロ化しましたが、たかだかオウム返しのためにこんなマクロを書くのはいただけませんし、ClojureでLINE BOTを作る度に記述するのも気がひける、ということでライブラリ化です。
こんな感じで今回の最終成果物は生まれました。
まずはlein new
にてプロジェクトを作成した後、上記のコードを移植してきます。
$ lein new line-bot-sdk-clojure
次に、Webアプリケーションからローカルにある上記ライブラリを読み込み、動作確認をしていきます。
lein install
により、上記をローカルリポジトリに登録します。
$ cd /path/to/line-bot-sdk-clojure
$ lein install
ここまでくれば、ローカルのWebアプリケーションのproject.cljのdependenciesに[line-bot-sdk-clojure "0.1.0-SNAPSHOT"]
を追加できるようになります。
簡単な動作確認程度であればこれである程度はできるでしょう。
しかしながら今回はHerokuで動作させる前提なわけで、Herokuにデプロイしようものなら公開リポジトリに上げたいところです (buidpackをゴニョゴニョすればいいのかも知れませんが、今回は挑みませんでした) 。
というわけでClojarsに上げることにしました。
アカウントを登録した後、リポジトリのディレクトリにて以下のコマンドを打てばOKです。
$ lein deploy clojars
なお、以上を多少リファクタしたりしたものがこちらになります。呼び出しI/Fも多少変えています。