심심한데 봇이나 만들어 보자. (슬랙-지라 연동 봇) #1 Scala

개요

우리 회사에서는 스칼라(Scala)를 주 개발 언어로 삼고 있고, 슬랙(Slack)을 통해 커뮤니케이션을 한다. 그래서 자연히 스칼라로 슬랙에서 돌아갈 수 있는 봇을 찾게 되었다. 그런데 지라(Jira)까지도 연동하면 좋겠다는 생각이 들었다. 현재 지라를 통해 업무 및 스프린트 관리를 하는데, 기존의 API 연동만으로도 푸시 형태로 지라의 변동사항을 슬랙에서 추적할 수 있긴 하다. 하지만 종종 대화중 스프린트 일정을 확인하거나 이슈 내용을 확인할 때 간단한 명령 호출이나 필터링을 통해 자연스럽게 봇의 도움을 받으면 좋겠다는 생각을 하였다.

그럼 시작해보자.

스칼라로 만들어진 봇 구동하기

(관련 지식 : SBT, Typesafe Config)
스칼라/슬랙 봇/지라 셋 모두 이미 널리 쓰이는 플랫폼이므로, 우선 이미 만들어진 것이 없는지 검색부터 하였다. 아쉽게도 능력이 일천하여 완성품을 찾지는 못했는데, 대신 이미 스칼라로 뼈대가 갖추어진 봇을 찾았으니 scala-slack-bot(이하 스봇… 귀찮아서) 이다.

사용법은 간단하다.

1) 소스를 clone 한다.
2) 슬랙 사이트에서 봇을 위한 설정을 하고 키를 받는다.(소개 글 참고)
3) src/main/resource/application.conf의 api.key 값을 채워준다. (더 나은 방법은 필요한 부분만 복사해 secret.conf 를 만들어 쓰는 것이다.)
4) sbt run.

슬랙에 봇이 online으로 등장하면 귀엣말로 $hello 라고 입력해 보자. 응답을 받으면 성공이다.

스봇 이해하기

(관련 지식 : Scala, Akka Actors)
프레임워크를 사용하기 위해 모든 것을 다 알 필요는 없으므로 최소한만 알아보자. 스봇은 여러 개의 봇(AbstractBot 구현체)으로 구성되어 있고, 각각의 봇은 메시지를 처리하는 메시지 청취자(MessageListener)이며 이 MessageListener는 액터다. 다시 말해 스봇은 담당한 메시지를 처리하는 여러 액터들로 구성되어 있으며 이 각 액터들을 봇이라고 부른다.

액터는 기본 액터가 아니고 한창 잘 나가는 메시징 프레임워크인 아카(Akka)의 액터 되시겠다. 그러나 아카를 몰라도 그냥 새로운 DSL 접하는 느낌으로 보면 된다. 일단 우리를 반겨주었던 $hello 메시지부터 뜯어보자. 이 메시지에 대한 처리는 HelloBot.scala(io.scalac.slack.bots.hello)에 정의되어 있다.

// ...

class HelloBot(override val bus: MessageEventBus) extends AbstractBot {

  // ...

  override def act: Receive = {
    case Command("hello", _, message) =>
      publish(OutboundMessage(message.channel, s"hello <@${message.user}>,\\n $welcome"))

    case BaseMessage(text, channel, user, _, _) =>
      BotInfoKeeper.current match {
        case Some(bi) if text.matches("(?i)(^|\\s*)(hi|hello)($|(\\s+.*))") && (text.contains(bi.id) || text.contains(bi.name)) && user != bi.id =>

          //multiline message example
          // new line sign `\n` should be double escaped, slash twice.
          publish(OutboundMessage(channel, s"""hello <@$user>,\\n $welcome"""))

        case _ => //nothing to do!

      }

  }

  // ...
}

보면 act 메서드가 오버라이드 되어 있는데, 바로 슬랙에서 들어온 메시지를 처리하는 메서드다. 내용을 보면 Command와 BaseMessage를 처리하는데, 둘은 core에 정의된 케이스 클래스다. 전자는 '$'로 시작하는 정형화 된 메시지(커맨드)를 추상화한 것이고 BaseMessage는 보다 로우레벨의 메시지로 그 밖의 다양한 메시지를 다루기 위해 쓰인다. 예컨대 $issue [이슈번호] 식의 명령 처리에는 커맨드가 적합하지만, 문장 중에 아무렇게나 이슈 번호를 언급했을 때 그것에 대해 봇이 추가 정보를 제공하는 기능을 생각한다면 BaseMessage로 처리해야 한다. 참고로 '$' 접두어는 sclack-scala-bot-core의 CommandsRecognizerBot에 하드코딩 되어 바꿀 수 없으므로, IRC에서 친숙한 ! 같은 문자를 사용하려면 처리기를 추가하거나 패치가 필요하다.

이제 우리의 봇을 만들어 보자. hello/HelloBot.scala를 복사해 jira/JiraBot.scala를 만들어 넣자. 불필요한 코드를 지우고 $hello를 $issue로 슬쩍 고쳐넣자. 그리고 봇들을 실행하는 주체인 DefaultBotBundle에 우리의 jiraBot을 하나 끼워넣기만 하면 끝이다. (물론 클론-수정이 아닌 확장을 만드는 더 나은 방법이 있겠지만 귀찮으므로 나중에 알아보기로 한다.)

package io.scalac.slack.common

// ...

class DefaultBotBundle extends BotModules {

  override def registerModules(context: ActorContext, websocketClient: ActorRef) = {

// ...
    val helloBot = context.actorOf(Props(classOf[HelloBot], bus),  "helloBot")
    val jiraBot = context.actorOf(Props(classOf[JiraBot], bus),  "jiraBot")
// ...
  }
}

이제 봇을 다시 실행하고 $issue로 말을 걸어 보자. 응답을 받으면 성공이다.

지라 연동하기

갓 만들어낸 지라 봇이 실로 환상적이기는 하지만 아직 제 역할을 하는 건 아니다. 명령대로 이슈 번호를 넣으면 이슈 제목과 주소를 반환하도록 만들어 보자. 지라는 널리 쓰이는 최신 소프트웨어 답게 Rest API를 제공한다. 또 자바가 쓰이는 분야에서 널리 쓰이므로 자바 어플리케이션에 통합하기 위한 자바 래퍼도 추가로 제공한다. 아쉽게도 후자는 문서화가 잘 되어 있지 않지만 bitbucket을 볼때 개발은 활발히 진행되는 것 같다.

일단 build.sbt에 아래 두 라이브러리를 추가한다. 전자는 자바 클라이언트인데, 비동기 API를 위해 자체 구현한 Promise를 사용한다. 그리고 Promise의 인자로는 Guava의 Function을 사용한다. 그러나 우리는 스칼라를 사용하는 덕에 이럴 필요가 없으므로 일반적인 익명 함수를 만들고 던지기 전에 Guava Function로 변환만 하면 된다. 이때 사용되는 것이 바로 mango 이다.

libraryDependencies ++= {
//...
  "com.atlassian.jira" % "jira-rest-java-client" % "2.0.0-m2",
  "org.feijoas" %% "mango" % "0.12"
//...
}

// JRJC는 메이븐 중앙 저장소에는 배포되지 않으므로 이 저장소를 추가해야 한다.
resolvers += "releases" at "https://maven.atlassian.com/public/"

다음은 실제로 이를 구현해 본 것이다.

package io.scalac.slack.bots.jira

import java.net.URI

import com.atlassian.jira.rest.client.internal.async.AsynchronousJiraRestClientFactory
import com.atlassian.jira.rest.client.domain.Issue
import com.atlassian.util.concurrent.{ Promise => AtlassianPromise }

import io.scalac.slack.MessageEventBus
import io.scalac.slack.bots.AbstractBot
import io.scalac.slack.common._

import org.feijoas.mango.common.base.Functions._

class JiraBot(override val bus: MessageEventBus) extends AbstractBot {

    val factory = new AsynchronousJiraRestClientFactory
    val jiraUri = new URI("http://JIRA_URI/")
    val restClient = factory.createWithBasicHttpAuthentication(jiraUri, "YOUR_ID", "YOUR_PW")

    override def act: Receive = {
        case Command("issue", issueKey :: _, message) =>
            val issuePromise:AtlassianPromise[Issue] = restClient.getIssueClient.getIssue(issueKey)

            issuePromise.map({ issue:Issue =>
                val issueUri = jiraUri + "browse/" + issue.getKey
                publish(OutboundMessage(message.channel, s"${issue.getKey} : ${issue.getSummary} $issueUri)"))

                return null
            }.asJava)
    }

    override def help(channel: String): OutboundMessage = OutboundMessage(channel, 
        s"주어진 키에 해당하는 이슈의 제목과 주소를 알려줍니다.")
}

URI와 인증 부분은 알아서 세팅 하시라. 특별히 어려운 내용은 없을 것이다. 핵심은 1) 커맨드의 두 번째 인자로 parameters가 들어온다는 것. 2) getIssue의 응답이 스칼라의 Future가 아니라는 것. 3) 그래서 익명 함수를 (타입 추론이 가능하게끔) null을 반환하도록 선언한 뒤 asJava 메서드로 Guava 함수로 변환하여 보내는 부분이다.

무조건 첫 번째 인자만 처리하게 했는데 여러 개의 이슈를 인자로 받아 확인하도록 만들 수도 있다.

// ...
    override def act: Receive = {
        case Command("issue", issueKeys, message) =>
            import context.dispatcher

            issueKeys.map(key => Future(restClient.getIssueClient.getIssue(key).claim()))
                    .foreach {
                        _.foreach { issue =>
                            val issueUri = jiraUri + "browse/" + issue.getKey
                            publish(OutboundMessage(message.channel, s"${issue.getKey} : ${issue.getSummary} $issueUri"))
                        }
                    }
    }
// ...

이번 예시는 일부러 조금 다르게 구성했다. _.claim()은 아틀라시안의 Promise에 있는 기능으로 마치 Await.result 처럼 결과를 반환하는 블러킹 메서드다. 이를 활용하는 대신, 이 예시에서는 mango를 사용하지 않았다. 또한 메시지 처리 중 블러킹되는 것을 막기 위해 Future로 감싸 주었다.

남은 과제

떠오르는 과제들을 살펴보면 다음과 같다.
1) 굳이 $issue [이슈번호] 식으로 호출할 필요 없이 누군가 언급하면 자연스럽게 내용을 표시해 주는 것이 좋을 것이다. 단 반복해서 거론될 경우를 대비해 다음 표시때까지 시간이나 메시지 수를 기준으로 무시 시간을 만들면 더 좋겠다.
2) com.atlassian.util.concurrent.Promise와 com.google.common.base.Function 혹은 mango를 사용할 필요 없이 스칼라의 Future/Function을 바로 쓸 수 있으면 좋을 것이다. 즉 별도로 지라의 스칼라 클라이언트를 만들면 좋겠다.
3) URI/ID/PW 등 설정을 Typesafe Config으로 별도 분리하고 JiraBot 자체를 모듈화한다.
4) 현재 스프린트 정보 등 그린호퍼(애자일) 쪽 데이터를 가져오려면 REST API에 직접 엑세스해야 한다. JRJC에서는 아직 지원되지 않고 있다. 패치하거나 직접 구현하거나 혹은 JRJC 외에 다른 클라이언트를 찾는 것이 필요하다.
5) 역시 난 $보단 !가 익숙하다.

이 것들은 다음 시간에 하나씩 해보도록 하겠다. (시간이 된다면...)

Tag :
, , , , ,

Leave Comments