継続モナドを利用したSlack Botを作ってみた

はじめに

最近Slack Bot作りに興味を持ち、どうせSlack Botを作るならば最も使い慣れた言語であるScalaで作りたくなった。また、継続モナドを使ってWebアプリケーションのコントローラーを自由自在に組み立てるを読んで、継続モナドを利用することでSlack Botもより柔軟に作れるのではないかという考えのもと、Scalaを利用して作ってみた。この記事では、まずこのSlack Botの動かし方を説明し、すごく簡単にこのSlack Botでどのように処理を書くかについて述べる。

発表資料

この内容について発表した資料が下記にある。

動かしてみる

次の手順で動かすことができる。

  1. リポジトリをGitで持ってくる
  2. ./src/main/resources/default.confをエディターで開く
  3. 次のtokenのところにSlack Botのトークンを書き込む

    slack {
      token = "<Your Slack Bot Token>"
      duration = "5"
    }
    • <Your Slack Bot Token>を消して書き換える
  4. Javaをインストールして./sbt run Mainを実行する

中身の説明

SlackCont

まず、SlackContというデータ構造について説明する。これは次のように定義されている。

case class SlackEnv(client: SlackClient, ec: ExecutionContext)

type SlackCont[A] = SlackEnv => ContT[Future, Unit, A]

これはSlackにメッセージを送信したりするSlackClientと、Futureを操作するために利用するExecutionContextの組であるSlackEnvという型を引数にとって継続モナドContTを返す関数のエイリアスである。関数にせずReaderTでもいいかと思ったが、モナドが重なりすぎていて大変になると思い、このようにした。

このモナドを利用して、次のようなSlack Botをどう書けるのかを見てみる。

  1. メッセージの内容がHelloかどうかを検査する
  2. もしメッセージがHelloならば書き込み中ステータスにする
  3. 2秒間待機する
  4. Worldという文字列を投稿する

HelloWorldCont

もしメッセージがHelloに一致するならば継続にWorldを渡す処理は次のように書ける。

object HelloWorldCont {
  def apply(message: Message): SlackCont[String] = SlackCont[String] { env =>
    implicit val ec: ExecutionContext = env.ec

    k =>
      for {
        _ <- if (message.text == "Hello") {
          k("World")
        } else {
          Future.successful(())
        }
      } yield ()
  }
}

このようにHelloでなかった場合は継続kを利用せず、そのまま成功を返して終了する。

TypingCont

Slackでタイピング状態を送信する処理は次のように書ける。

class TypingCont @Inject()(
  threadSleep: ThreadSleep
) {
  def apply(channelId: String, msec: Long): SlackCont[Unit] = SlackCont { env =>
    implicit val ec: ExecutionContext = env.ec

    k =>
      env.client.indicateTyping(channelId) // 失敗を無視する
      threadSleep.sleep(msec)
      k(())
  }
}

まず、スリープする処理はテストのときに邪魔なのでGuiceでDIしている。そして、Slackクライアントにタイピングする命令を送り、その結果に関わらず継続を実行する。

SayCont

引数に渡されたチャンネルに、メッセージを投稿する処理は次のように書ける。

object SayCont {
  def apply(sendChannelId: String, sendMessage: String): SlackCont[Long] = SlackCont[Long] { env =>
    implicit val ec: ExecutionContext = env.ec

    k =>
      for {
        n <- env.client.sendMessage(SlackSendMessage(sendChannelId, sendMessage))
        r <- k(n)
      } yield r
  }
}

この処理ではenv.client.sendMessageFuture[Long]を返すので、もし投稿に失敗してFuture.failedとなった場合にはFutureflatMapが失敗し継続は実行されない。

Main

それではこれらを組み合せて実際に動くようにしてみよう。

val injector = Guice.createInjector(new DefaultModule)

val slackRunner = injector.getInstance(classOf[SlackRunner])

val typingCont = injector.getInstance(classOf[TypingCont])

slackRunner.onMessage(msg =>
  for {
    world <- HelloWorldCont(msg)
    _ <- typingCont(msg.channel, 2000)
    _ <- SayCont(msg.channel, world)
  } yield ()
)

このように、比較的直感的に処理を組み合わせていくことができる。

まとめ

このように継続モナドを組み合せてSlack Botを作ってみたが、まだ機能がそんなにないので継続を利用したおもしろい処理を書けていない。もしそういう機能を実装できたら、また追記したいと思う。

コメント