AkkaHTTP で作る LINE bot

akka

お久しぶりです。最近は社内ですら「最近何やってるんですか?」って聞かれるので、最近ちょっと試していたAkkaHTTP で LINE bot を作るやり方をご紹介します。

LINEの提供するMessaging APIを使えば、LINE@で作成したLINEアカウントを使ってユーザーに任意のタイミングでメッセージを送ったり、ユーザーからのメッセージに返答することができます。

Messaging APIは登録してシークレットとアクセスキーさえ発行できれば、あとはJSONでやり取りする普通のAPIなので、AkkaHTTPがあれば簡単にbotが作れてしまいます。もちろんビジネスロジックはこっちで考えないといけないけど、細かいことはいいからまずは動くものをサクッと作って見てから考えるのはいかがでしょうか?

できること

  • ともだち登録してくれたユーザーにメッセージ
  • ユーザーからの発言に答える
  • 任意のタイミングでpushメッセージを送る

*ただし、pushメッセージはプロ(API)プランでないと送ることができないようです。テストだけなら、テスト用アカウントで送れるので試すことはできます。
参考:Messaging APIのご紹介 | LINE Business Center

作り方

大まかな動作としては以下のような動きをさせることになります。

  1. webhookを受け付ける
  2. 署名検証して本物のLINEからのメッセージであることを確かめる
  3. 受け取ったwebhookの Event をパースする
  4. Event ごとに処理を決めつつとりあえず応答(200)を返す
  5. APIを叩いてユーザーにメッセージを送る
  6. 任意のタイミングでpushメッセージを送信する

署名検証は簡単な実装でなんとかなるし、webhookをEventに変換したり、逆にこちらからLINEのAPIを叩いたりはJSONを扱うライブラリにお任せ。OAuthにももちろん対応しており、「OAuth使ってね」って書くだけの簡単な実装で済みます。

準備するもの

  • LINE Business Centerで会社を登録
  • ビジネスアカウントを作成する
  • 検証用なら “Developer Trialを始める” から検証用アカウントが作れます
  • LINE@ MANAGERでBot利用を設定し、Webhookを有効にする
  • LINE developersでChannel SecretとChannel Access Tokenを確認しておく
  • webhookが受け付けられるサーバー
  • そのサーバーをwebhookに設定しておく

ちなみに私はサーバーはherokuで試しました。HTTPS周りで面倒、と聞いていたのですが特に何かするわけでもなくすんなり使えました。

1.webhookを受け付ける

AkkaHTTPを活用して、httpリクエストを受け付けましょう。

object Boot extends App {
implicit val system = ActorSystem()
implicit val materializer = ActorMaterializer()
implicit val executionContext = system.dispatcher
val routes =
path("line" / "callback") {
post {
complete("OK")
}
}
Http().bindAndHandle(routes, "0.0.0.0", 8080)
}
これだけで、 http://localhost:8080/line/callback に来た post リクエストに対して 200 を返すことができます。
2. 署名検証して本物のLINEからのメッセージであることを確かめる
署名検証自体はそこまで難しいものではありません。LINEのAPI Referenceに載ってるJavaの例をそのまま使って、とりあえず署名検証クラスを作ることにしましょう。
class LineSignatureVerifier(val channelSecret: String) {
def isValid(bodyString: String, signature: String): Boolean = {
val key = new SecretKeySpec(channelSecret.getBytes, "HmacSHA256")
val mac = Mac.getInstance("HmacSHA256")
val source = bodyString.getBytes(StandardCharsets.UTF_8)
mac.init(key)
Base64.getEncoder.encodeToString(mac.doFinal(source)) == signature
}
}
使い方はこんな感じです
// シークレットを渡して検証用クラスを生成
val lineSignatureVerifier = new LineSignatureVerifier(channelSecret)
// リクエストのbodyと、リクエストヘッダーにあったsignatureを渡して検証
lineSignatureVerifier.isValid(body, signature)
これを活用して、 Directive を作ることで、 routes の中にこの署名検証を組み込むことができます。
// こういうのを作って
val lineSignatureVerifier = new LineSignatureVerifier(config.channelSecret)
def verifyLINESignature: Directive0 = (headerValueByName("X-Line-Signature") & entity(as[String])).tflatMap {
case (signature, body) if lineSignatureVerifier.isValid(body, signature) => pass
case _ => reject(ValidationRejection("Invalid signature"))
}
// こうする
val routes =
path("line" / "callback") {
(post & verifyLINESignature) {
complete("OK")
}
}
これで、 post かつ署名検証がパスしたものだけが処理されるようになります。試しにローカルで動かして見てください。署名がないので怒られることを確認しましょう。
3. 受け取ったwebhookの Event をパースする
さて、webhookで受け取るのはイベントを表すJSONです。JSONはAkkaHTTPでサクッとパースしましょう。
まずはEventやその小要素のためのcase classを用意して
package object models {
sealed trait Event {
val `type`: String
val timestamp: Long
val source: Source
}
case class Events(events: List[Event])
sealed trait Message {
val `type`: String
}
case class MessageEvent(replyToken: String, timestamp: Long, source: Source, message: Message) extends Event {
override val `type`: String = "message"
}
sealed trait Source {
val `type`: String
}
case class TextMessage(id: String, text: String) extends Message {
override val `type`: String = "text"
}
case class UserSource(userId: String) extends Source {
override val `type`: String = "user"
}
}
それをパースするtraitを作ります。
今回はおそらく一般的と思われるSprayJsonで。JSONの扱いはSprayJsonである必要はなく、いろんなライブラリが使えますからぜひお気に入りのパーサーでパースしてくださいね。(本筋からはそれるのでここでは語りません)
trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
implicit object SourceJsonFormat extends RootJsonFormat[Source] {
def write(obj: Source): JsValue = obj match {
case user: UserSource => JsObject(
"type" -> JsString(user.`type`),
"userId" -> JsString(user.userId)
)
}
def read(json: JsValue): Source = json.asJsObject.getFields("type", "userId") match {
case Seq(JsString("user"), JsString(userId)) => UserSource(userId)
case _ => deserializationError("Source expected")
}
}
implicit object MessageJsonFormat extends RootJsonFormat[Message] {
def write(obj: Message): JsValue = obj match {
case textMessage: TextMessage => JsObject(
"type" -> JsString(textMessage.`type`),
"id" -> JsString(textMessage.id),
"text" -> JsString(textMessage.text)
)
}
def read(json: JsValue): Message = json.asJsObject.getFields("type", "id", "text") match {
case Seq(JsString("text"), JsString(id), JsString(text)) => TextMessage(id, text)
case _ => deserializationError("Message expected")
}
}
implicit object EventJsonFormat extends RootJsonFormat[Event] {
def write(obj: Event): JsValue = obj match {
case messageEvent: MessageEvent => JsObject(
"type" -> JsString(messageEvent.`type`),
"timestamp" -> JsNumber(messageEvent.timestamp),
"source" -> SourceJsonFormat.write(messageEvent.source),
"replyToken" -> JsString(messageEvent.replyToken),
"message" -> MessageJsonFormat.write(messageEvent.message)
)
}
def read(json: JsValue): Event = {
json.asJsObject.getFields("type", "timestamp", "source", "replyToken", "message") match {
case Seq(JsString("message"), JsNumber(timestamp), source, JsString(replyToken), message) =>
MessageEvent(replyToken, timestamp.toLong, SourceJsonFormat.read(source), MessageJsonFormat.read(message))
case _ => deserializationError("Event expected")
}
}
}
implicit val EventsJsonFormat = jsonFormat1(Events)
}
あとはこれを最初に作ったBootに混ぜ込んでやれば良いのです。
4. Event ごとに処理を決めつつとりあえず応答(200)を返す
routes をちょちょっといじって、 Event に対応させましょう。
object Boot extends App with JsonSupport {
implicit val system = ActorSystem()
implicit val materializer = ActorMaterializer()
implicit val executionContext = system.dispatcher
val routes =
path("line" / "callback") {
(post & verifyLINESignature) {
entity(as[Events]) { request =>
request.events.foreach {
case e: MessageEvent => println(s"catch event: $e")
case _ => println("no match")
}
complete("OK")
}
}
}
Http().bindAndHandle(routes, "0.0.0.0", 8080)
}
entity(as[Events]) で、そのリクエストを Events として解釈できたものだけ処理することができます。 Event ではなく複数なことに注意しなくてはいけません。そのためこの段階では、一度それぞれはただprintlnするだけにして、最後に complete("OK") しています。
5. APIを叩いてユーザーにメッセージを送る
いよいよAPIを叩いてユーザーに返事を返しましょう。
APIを叩くにはOAuth認証をどうにかする必要があります。が、これはAkkaHTTPがなんとかしてくれます。
val replyUri = "https://api.line.me/v2/bot/message/reply"
val auth = headers.Authorization(OAuth2BearerToken(accessToken))
val reply = ...
Http().singleRequest(RequestBuilding.Post(replyUri, reply).withHeaders(auth))
これだけです。 reply に必要なデータを詰め込めば、たったこれだけでOAuthをよしなになんとかしてくれます。
じゃあこの reply は何をすればいいの?って話なのですが。API Referenceに基づいて、replyTokenとSend message objectを詰め込んでやりましょう。Eventと同じ要領でJsonSupportに追記してやれば、パースだけではなくJSONを作ることもできるようになります。( reply がパース、 write がJSONの書き出しになります)
6. 任意のタイミングでpushメッセージを送信する
基本的に返事を返すReplyと大差ありません。
必要なものは、送るメッセージの他にOAuth用のアクセストークン、そして送り先のIdentifierになります。
val pushUri = "https://api.line.me/v2/bot/message/push"
val auth = headers.Authorization(OAuth2BearerToken(accessToken))
val push = ...
Http().singleRequest(RequestBuilding.Post(pushUri, push).withHeaders(auth))
こちらに詰め込む push はSend message objectの他に、replyTokenの代わりに`送信先識別子`というものを詰め込みます。これはどうしようか迷うところだけど、一番手っ取り早いのはLINE developersで Channels > Basic information を見たときの一番下にある`Your userId`です。それは、あなた自身のIdentifierなはずなので、そこに送ればあなたに届きます。
最後に
さあ、サーバーにアップロードしてLINEで友達登録したり、メッセージを送ったりして見ましょう。うまく動きましたか?普段使ってるLINEが、こうやって自分の手で作ったbotでメッセージを返してくれるとなんか嬉しくなりますよね。
さて、これで基本はバッチリです。あとはAPI Referenceを色々見て、他の様々なメッセージに対応したり、メッセージの本文をちゃんと読んでビジネスロジックをがっつり組み込んでやったりしてください。Messaging APIはあくまでもAPI、どう活かすかは自分次第です。
参考情報
Getting started with the Messaging API