虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

BacklogAPIとKotlinで問合せを自動管理するツールを作ってみた

お久しぶりです、最近は料理系Youtuberの影響を受けて自炊ブームが来ている礒部です。1万円する包丁を買ってからキャベツの千切りが趣味になっています。

今回はKotlinで作成した、問合せ管理を自動化するツールの紹介をしたいと思います。

作ったきっかけ

通販チームでは、運営チームからの問合せをエンジニアで順番に対応しています。

元々は Googleスプレッドシート + GAS で問合せを自動管理していたのですが、Backlogに移行したためBacklog用の問合せ自動管理ツールを作ることになりました。

Googleスプレッドシート で運用するデメリット

  • 問合せシートのデータが肥大化してきており、動作が重く、過去の問合せの参照に時間がかかる
  • 画像の貼り付けやファイルの添付がスムーズに行えない
  • 問合せとは別に、確認や相談はBacklogで行っている場合などがありバラバラになっていた

Backlogに移行するメリット

  • 課題毎に担当者設定ができるため、誰が今担当中なのかが一目でわかる
  • 過去の問合せが簡単に検索できる
  • 課題のメモに、対応方法や調査メモを書き込むことで将来的に参考にできる
  • Backlog記法、Markdown記法が使える

構成

今回紹介するBacklogに書き込まれた問い合わせを自動管理するシステムの構成は、以下のようになっています。

f:id:toranoana-lab:20210618105224p:plain

システムが行うのは以下の3つです。

  1. 新規問合せを取得し、担当者を決定し通知する
  2. 既存の問合せの状態変化を検知する
  3. 1日の終わりに、未完了の全ての問合せの状況を通知する

常時起動させる必要もないシステムなので、Kotlin+Ktorで作成したjarファイルを、crontabで1分毎に起動させる簡単な構成にしました。

HTTPライブラリに、Kotlinのfuelを使用します。

github.com

実装

DB作成

割り当て対象のユーザ一覧と、問合せ状況管理を行うDBを作成します。

user_list(ユーザ一覧)

論理名 テーブル定義 役割
id INT auto_increment PRIMARY KEY 連番ID
user_id VARCHAR(255) Slack通知に利用するユーザーID
user_name VARCHAR(255) Slack通知に利用するユーザー名
backlog_user_id VARCHAR(255) BacklogAPIに利用するBacklogユーザーID
next_flg TINYINT(1) 問合せの最新対応者フラグ。このフラグがついている人の次の人が問合せ対応者になる。

ask(問合せ状況管理)

論理名 テーブル定義 役割
id INT auto_increment PRIMARY KEY 連番ID
ask_id VARCHAR(255) 問合せ番号
ask_title VARCHAR(255) 問合せタイトル
assignee_id VARCHAR(255) 担当者ID(ラボ)
assignee_name VARCHAR(255) 担当者名(ラボ)
request_name VARCHAR(255) 依頼者名
comments_id VARCHAR(255) 最新コメントID
due_date VARCHAR(255) 期限日
status VARCHAR(255) 問合せ状況ステータス

新規問合せを取得し、担当者を決定し通知する

以下手順を追って実装内容を説明します。

checkNewAsk() というメソッドを作成します。

BacklogのDB操作のメソッドを作成し呼び出していますが、DBから取得や登録などをしているだけの簡単な内容なので詳細は割愛します。

BacklogAPIの詳しい仕様は、公式ドキュメントを参照して下さい。

developer.nulab.com

object Execute {
    FuelManager.instance.forceMethods = true // これを設定しないとpatchメソッドが実行できない
    private val mapper = ObjectMapper()
    private const val channel1PostUrl = "通知したいSlackWebhook"
    private const val backlogApiKey = "BacklogのAPIキー"

    fun checkNewAsk() {
        // ユーザ一覧テーブル取得
        val userList = getUserList()
        val lastNumber = userList.size - 1

        // 問合せ状況管理テーブル取得
        val askList = getAsk()
        val lastAskId = askList.last().ask_id
        var beforeTargetId = 0
        var targetId = 0

        // 次の対象者を決定する
        userList.mapIndexed { index, it ->
            if (it.next_flg) {
                if (index == lastNumber) {
                    beforeTargetId = lastNumber
                    targetId = 0
                } else {
                    beforeTargetId = index
                    targetId = index + 1
                }
            }
        }

        // BacklogAPIを実行し新規問合せを取得
        val newIssuesResponse = Fuel.get("https://[BacklogURL]/api/v2/issues?apiKey=${backlogApiKey}&projectId[]=[取得したいprojectID]&count=1").responseString()
        val newIssuesResponseJson = newIssuesResponse.third.get()
        val newIssuesResponseMap: Map<String?, Any?>? = mapper.readValue<Map<String?, Any?>>(newIssuesResponseJson, object : TypeReference<Map<String?, Any?>?>() {})
        val issueKey = newIssuesResponseMap?.get("issueKey") as Map<String?, Any?>

        // 問合せ状況管理テーブルに最後に登録済みの問合せ番号と一致しないならば、新規問合せとする
        if (lastAskId != issueKey) {
            // 担当者を決定
            val assigneeId = userList[targetId].backlog_user_id
            val assigneeName = userList[targetId].user_name

            // 期日を取得
            val dueDate = newIssuesResponseMap?.get("dueDate") as String?
            var castDueDate = "未設定"
            if (dueDate != null) {
                castDueDate = dueDate.take(10)
            }

            // 問合せ名取得
            val issueType = newIssuesResponseMap?.get("issueType") as Map<String?, Any?>
            val askTitle = newIssuesResponseMap?.get("summary") as String
            val issueName = issueType["name"]

            // 問合せ依頼者名取得
            val createdUser: Map<String?, Any?>? = newIssuesResponseMap?.get("createdUser") as Map<String?, Any?>?
            var requestName = "依頼者名不明"
            if (createdUser != null) {
                requestName = if (createdUser["name"] == null) "依頼者名不明" else createdUser["name"].toString()
            }

            // 問合せ状況管理テーブルに登録
            insertAsk(newAskId, askTitle, assigneeId, assigneeName, requestName, castDueDate, "未対応")

            // ユーザ一覧テーブルの対応者を1つ進める
            updateUserListStatus(userList[beforeTargetId].id, false)
            updateUserListStatus(userList[targetId].id, true)

            // Backlogの課題に担当者を設定する
            Fuel.patch("https://[BacklogURL]/api/v2/issues/${issueKey}?apiKey=${backlogApiKey}&assigneeId=$assigneeId").responseString()

            // Slack通知する
            val text = " <@${userList[targetId].user_id}> さん 通販の新規問合せがあります。\n期限: $castDueDate \nhttps://[BacklogURL]/view/$newAskId"
            postToSlack(ecPostUrl, text)
        }
    }

    // Slack通知する
    private fun postToSlack(url: String, text: String) {
        val body = "{ \"text\" : \"$text\" }"

        Fuel.post(url).body(body).response()
    }
}

ハマったポイントとしては、APIの実行をfuelで行っていますがこちらの記述がないとPATCHメソッドが実行できません。

FuelManager.instance.forceMethods = true

デフォルトで勝手にPOSTとして実行してしまうので気をつけましょう。

github.com

これで新規問合せを担当者設定し、Slack通知してくれるようになりました。

f:id:toranoana-lab:20210618190716p:plain

既存の問合せの状態変化を検知する

checkNotCompleteAsk() というメソッドを作成します。

具体的にやる事は以下のとおりです。

  • 課題の担当者の変更を取得し、DB更新をする
  • 課題の新規コメントを取得し、DB更新をしSlack通知する
  • 課題の対応状況を取得し、DB更新をする
object Execute {
    fun checkNotCompleteAsk() {
        // ユーザ一覧テーブル取得
        var userList = getUserList()

        // 未完了の問合せを取得
        val notCompleteAsk = getNotCompleteAsk()

        // 既存の問合せの情報更新
        notCompleteAsk.map loop@ {
            // 課題情報の取得
            val issuesResponse = Fuel.get("https://[BacklogURL]/api/v2/issues/${it.ask_id}?apiKey=${backlogApiKey}").responseString()
            val issuesResponseJson = issuesResponse.third.get()
            val issuesMap: Map<String?, Any?>? = mapper.readValue<Map<String?, Any?>>(issuesResponseJson, object : TypeReference<Map<String?, Any?>?>() {})
            val status: Map<String?, Any?> = issuesMap?.get("status") as Map<String?, Any?>
            val statusName = status["name"] as String

            // ステータスが更新されていた場合、DBを更新する
            if (it.status != statusName) {
                updateStatusAsk(it.ask_id, statusName)
            }

            // 完了になっていた場合は次のループに行く
            if (statusName == "完了") {
                return@loop
            }

            // 担当者が変わっていた場合、DBを更新する
            val assignee: Map<String?, Any?>? = issuesMap?.get("assignee") as Map<String?, Any?>?
            var assigneeId = "担当無し"
            var assigneeName = "担当無し"
            if (assignee != null) {
                assigneeId = if (assignee["id"] == null) "担当無し" else assignee["id"].toString()
                assigneeName = if (assignee["name"] == null) "担当無し" else assignee["name"].toString()
            }

            // コメント情報の取得
            val commentsResponse = Fuel.get("https://[BacklogURL]/api/v2/issues/${it.ask_id}/comments?apiKey=${backlogApiKey}&count=1").responseString()
            val commentsResponseJson = commentsResponse.third.get()
            val commentsMapList: List<Map<String?, Any?>> = mapper.readValue<List<Map<String?, Any?>>>(commentsResponseJson, object : TypeReference<List<Map<String?, Any?>?>>() {})
            val commentsMap: Map<String?, Any?> = commentsMapList[0]
            val commentsId = commentsMap["id"].toString()
            val createdUser: Map<String?, Any?> = commentsMap?.get("createdUser") as Map<String?, Any?>
            val commentsUserId = createdUser["id"].toString()
            val content = commentsMap["content"]

            // 担当者以外の新規コメントがある場合、DBの最新コメントIDを更新しコメントを通知する
            if (it.comments_id != commentsId && assigneeId != commentsUserId && content != null) {
                updateCommentsId(it.ask_id, commentsId)
                val userName = getUserName(assigneeId)
                val text = "$userName さん 問合せに新規コメントがあります。 \n\n```${content}```\nhttps://[BacklogURL]/view/${it.ask_id}"
                postToSlack(ecPostUrl, text)
            }
        }
    }
}

これで未完了の問合せの各種更新が行えるようになります。

また、課題についた新規コメントを拾う事ができるようになりました。

f:id:toranoana-lab:20210618190732p:plain

1日の終わりに、未完了の全ての問合せの状況を通知する

notification() というメソッドを作成します。

問合せの対応状況を通知し、誰が今何の問合せを持っているのかを1日の最後に1回通知するようにします。

忘れたり、対応漏れの防止のための通知を行います。

object Execute {
    fun notification() {
        // 平日18時に、タスク状況を通知する
        val notCompleteAsk = getNotCompleteAsk()
        val now = LocalDateTime.now()
        if (now.hour == 18 && now.minute == 0 && listOf(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.TUESDAY, DayOfWeek.FRIDAY).contains(now.dayOfWeek) && notCompleteAsk.isNotEmpty()) {
            var labText = "<!グループメンション> \n<[未完了課題一覧URL]|Backlog未完了問合せ一覧>\n以下の問い合わせの【対応】が完了していません。ラボ担当者は対応をお願いします。\n```"
            var operationText = "\n\n以下の問い合わせの【確認】が完了していません。依頼者は確認をして問題なければ完了にお願いします。\n```"

            notCompleteAsk.map {
                if (it.status == "処理済み") {
                    operationText += "\n${it.ask_id} ${it.ask_title}\n  対応期限:${it.due_date}, ステータス:${it.status}, 依頼者:${it.request_name}\n"
                } else {
                    labText += "\n${it.ask_id} ${it.ask_title}\n  対応期限:${it.due_date}, ステータス:${it.status} 対応者:${it.assignee_name}\n"
                }
            }
            labText += "```"
            operationText += "```"
            val text = labText + operationText
            postToSlack(tuhankaPostUrl, text)
        }
    }
}

これで平日の18時に、未完了の問合せの状況を通知するようになります。

f:id:toranoana-lab:20210618190746p:plain

mainから呼び出す

あとは作成したメソッドをmainから呼び出すだけです。

// Application.kt
fun main(args: Array<String>) {
    Execute.checkNewAsk()
    Execute.checkNotCompleteAsk()
    Execute.notification()
}

jarを実行可能にする

crontabで起動させるので、実行可能jarにするために build.gradle にjarファイルに全て入れ込んでビルドするように指定します。

// build.gradle
jar {
    manifest {
        attributes 'Main-Class': "com/toiawase/ApplicationKt"
    }
    from {
        configurations.compile.collect {
            it.isDirectory() ? it : zipTree(it)
        }
    }
}

crontabに配置する

ビルドして、jarファイルを作成しcrontabに設定すれば完了です。

# ビルドする
$ ./gradlew

# crontab に配置する
$ crontab -e
* * * * * java -jar /tmp/toiawase/toiawase-0.0.1.jar

作ってみた感想

Kotlinは使いやすいですね!スピード感を持って開発できたので、大体1日で作成できました。

今後また簡単なバッチを作るときは同じような構成でやってみたいです。

注意事項

BacklogのAPIの上限数は人や組織によって違うようなので、1分に1回のペースで実行して問題ないかは確認が必要かと思います。

こちらのAPIで上限数が確認できます。

developer.nulab.com

P.S.

■ 採用情報

とらのあなでは、オタクなエンジニアを募集しています。
yumenosora.co.jp

カジュアル面談も随時開催中です。お申し込みはこちら!
news.toranoana.jp

■ ToraLab.fmスタートしました!

メンバーによるPodcastを配信中!是非スキマ時間に聞いて頂けると嬉しいです。
anchor.fm

■ Twitterもフォローしてくださいね!

ツイッターでも随時情報発信をしています。
twitter.com