メモ2ブログ

メモtoウェブログ。旧ブログはこちら。 http://sakebook.blogspot.jp/

Kotlin/JSでLINE Botを動かす

Kotlin/JSを扱う題材としてCloud FunctionsでLINE Botを動かしてみたのですが、前回の記事がモリモリになってしまうので導入と実践を分けました。今回が実践編です。

前回の記事はこちら

sakebook.hatenablog.com

環境は次のとおりです

  • Kotlin v1.3.72
  • dukat v0.0.28
  • Node v13.3.0
  • Google Cloud SDK v291.0.0

題材

LINE Botを動かしてみることにしました。Slackもそうですが、inputとoutputが同じ場所で見えるチャット系サービスはCloud Functionsと相性が良いです。

f:id:sakebook:20200520222139p:plain

Messaging API overview | LINE Developers

  1. ユーザーが、LINE公式アカウントにメッセージを送信します。
  2. LINEプラットフォームからボットサーバーのWebhook URLに、Webhookイベントが送信されます。
  3. Webhookイベントに応じて、ボットサーバーからユーザーにLINEプラットフォームを介して応答します。

今回は応答メッセージを使って、ユーザがLINE Botにメッセージを送ったときにオウム返しをするBotを作成します。

LINE Bot作成で必要なこと

LINE Botでは次のことが必要です

  • リクエストからデータを受け取り
  • 応答メッセージの作成
  • LINEサーバにメッセージの送信

Cloud Functionsで動かす部分は同じです。

開発を便利にするためにいくつかの準備をします。

型の用意

せっかくKotlinで書くので型の恩恵を得られるようにします。

デプロイする関数の引数であるRequestとResponseの型を定義してあげます。今回利用する最低限度のものしか用意してないです。

  • Wrapper.kt
external interface Request {
    val body: Any
}

external interface Response {
    fun status(code: Int): Response
    fun send(message: String): Response
}

デプロイする関数を用意して、関数型として渡します。

  • main.kt
import wrapper.Request
import wrapper.Response

external val exports: dynamic

fun main() {
    exports.message = ::message
}

fun message(req: Request, res: Response) {
    TODO("実装を書く")
}

これでエントリーポイントと実装が分けられてスッキリします。

kotlin-extentionsの導入

Kotlin/JSを扱うに当たり、ヘルパーが用意されてるので導入します。

@jetbrains/kotlin-extensions という名前のnpmパッケージです。

www.npmjs.com

  • build.gradle.kts
dependencies {
        ...
    implementation(npm("@jetbrains/kotlin-extensions", "^1.0.1-pre.91"))
        ...
}

npmパッケージが用意されてるので利用はできるのですが、デプロイを考えたときにちょっと問題があります。

生成されるJSファイルでは、require('kotlin-extensions')となってしまうので利用時にエラーになります。

なので、JSファイルを生成する際に一部書き換えて対応します。

  • build.gradle.kts
compileKotlinJs {
        doLast {
            // workaround for use kotlin-extensions
            val jsFile = File("build/js/packages/${project.name}/kotlin/${project.name}.js")
            val text = jsFile.readText()
            val rep = text.replace("require('kotlin-extensions')", "require('@jetbrains/kotlin-extensions')")
            jsFile.writeText(rep)
        }
    }

正直ちょっと手間ではあるので、利用したい部分のコードをローカルにコピーしてくる運用でも良いかもしれません。

環境変数の利用

トークンなどはソースコード管理に含めたくないので環境変数を経由して利用します。

js関数でJSのコードを実行して利用します。

例えば

$ export CHANNEL_ACCESS_TOKEN=hogehoge

としていたら、Kotlinからは次のように利用できます。

js("process.env.CHANNEL_ACCESS_TOKEN") // hogehoge

型はdynamicです。

kotlinx.serializationの導入

通信の際にJSONデータを扱うので、手動でSerialize/deserializeしなくていいようにライブラリを利用します。

公式の通りにやれば追加できます。

github.com

注意として、バージョンは 0.14.00.20.0 しか対応していません。

package.jsonには kotlinx-serialization-kotlinx-serialization-runtime というnpmパッケージで探しに行くので、次のnpmパッケージが一致して解決されます。

www.npmjs.com

ちょっと気になるのは、このnpmパッケージが公式のものなのかどうかわからない点です。

npmパッケージの中身はbuild/package_importedに生成されるJSファイルと同じなので気のせいかもしれませんが。

issueが閉じられてないのも気になります。

できればextentionと同じようにscopeを切って公式とわかるようにして、苦なく利用できるような形になっててほしいです。

通信ライブラリの導入

Axiosがdukatでいい感じに利用できたので、Axiosを採用します。

dukatについてはこちら

sakebook.hatenablog.com

こんな感じでPOSTリクエストを送れます。HeaderにAuthorizationを追加してます。

  • main.kt
private fun postReply(reply: Reply): Promise<JsonObject> {
    val axios = Axios.create(object : AxiosRequestConfig {
        override var method: String = "POST"
        override var responseType: String = "json"
        override var headers: Any? = kotlinext.js.js {
            this.Authorization =
                "Bearer ${js("process.env.CHANNEL_ACCESS_TOKEN")}"
        } as? Any
    })
    return axios.post<Unit, JsonObject>(url = "https://api.line.me/v2/bot/message/reply", data = reply)
}

kotlinext.js.js は前述のkotlin-extentionsです。

JSON.stringifyでの循環参照回避

JSON.stringify(o: Any?) を利用することで、JSのオブジェクトをJSON文字列に変換できます。

JVMの世界では起きなかったが、JSの世界では起きる問題があります。

stringifyしようとしたJSオブジェクトが、循環参照している場合例外が発生します。

それを解決するために第2引数に replacer: ((key: String, value: Any?) -> Any?) という関数が用意されています。

こちらで循環参照してしまうものを除外して解決します。

次の例では、 request というkeyが循環参照していたので除外しています。

  • main.kt
fun message(req: Request, res: Response) {
    val webhookEvent = parseRequest(req)
    val reply = createReply(webhookEvent)
    postReply(reply)
        .then {
            console.log("then ${JSON.stringify(it) { key: String, value: Any? ->
                when (key) {
                    "request" -> ""
                    else -> value
                }
            }}")
            res.status(200).send("Success!!")
        }.catch {
            console.log("catch ${JSON.stringify(it)}")
            res.status(400).send("Error!!")
        }
}

普段JSを書いていないので、慣れている人からしたら当たり前かもしれません。自分は困りました。

Cloud Functionsへのデプロイ

デプロイ時に環境変数を渡すのを忘れないようにします。

$ ./gradlew clean cKJ // compileKotlinJsの略
$ cd functions
functions$ gcloud functions deploy message --region asia-northeast1 --trigger-http --runtime nodejs8 --update-env-vars CHANNEL_ACCESS_TOKEN="YOUR_ACCESS_TOKEN"

まとめ

JSならではの部分や、エコシステム周りで足りない部分も感じますが、準備さえしてしまえばわりと解決できる問題が多いです。

Kotlin/JSでHello World以上のサンプルがまだ少ないので、いくらか参考になると思います。

github.com

READMEにLINE Botの友だち追加のQRを置いてあるので、気になった方は試すことはできます。

参考

Messaging API overview | LINE Developers

Functional programming - Kotlin Programming Language

JSON.stringifyを改めて調べる。 - Qiita