LINE BOT とToodledoを連携してLINE上で買い物リストを閲覧・追加する【Playframework Scala】

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

世の中には多くの便利なアプリやサービスがあり、使いこなせれば日々の生産性がさらにあがること間違いナシですが、いかんせん数が多く、そのすべてのパワーを発揮するのは大変です。便利なサービスには、APIが用意されていることがほとんどです。我々はそういったAPIと、ちょっとしたエンジニアリングを駆使することで、様々な「口」からサービスの恩恵を受けることができるようになります。慣れ親しんだデバイスやアプリから別のサービスを使うことができれば、新しく覚えることも少なく済むはずです。

今回は、LINEから受け取ったメッセージに反応して、買い物リストを閲覧・追加するBOTを作ってみます。我が家では消耗品を気づいたベースで補充するようにしているのですが、毎日買うものならばまだしも、買い替え間隔が長いもの(シャンプーやボディソープなど)はついつい忘れがちです。タスク管理ツールを使ってリストで管理はしているのですが、肝心なときに見ず、帰ってから気づいて「やっちまったぜ」と嘆く日々。そこで、自分だけでなく、家族も普段触るアプリからタスク管理できるようにしてみようと、LINE BOT を再度活用してみることにしました。最終的に以下のようなことができるようになりました。

Toodledoの買い物リストについて
Toodledo画面

LINEで「買い物」に反応して抽出して返したり、買い物リストを追加したりする
LINE画面

本記事は外部APIと連携した LINE BOT の例を示すことを目的としています。途中、Herokuのアドオンを使うときにクレジットカードの登録が必要ですが無料です。

やること

今回はタスク管理ツールとしてToodledoを使います。自分でDBにテーブルを作ってそこで簡易的にタスク管理をするのでもよかったのですが、外部サービスを LINE BOT 経由で使ってみたかったという意図があります。これができれば、APIが利用可能な他のサービスで同じようにBOTから使えるようになるはずですね。

システム関連図は以下のようになります。

全体アーキテクチャ図

やることは以下です。

  1. Herokuに単純なアプリを登録して LINE BOT とPlayアプリが連携できることを確認する
  2. PlayアプリからToodledoのAPIを呼び出して、タスク一覧を取得する
  3. Toodledoから抽出したタスクリストを買い物リストに絞り、LINEのメッセージにはめこむ
  4. LINEのメッセージを「タスク閲覧」と「タスク追加」に振り分ける
  5. 試す

1. Herokuにアプリを登録する

まずは実行環境を整えます。買い物リストのことはいったん横において、LINE BOT がユーザから話しかけられたら固定の文言を返すプログラムを作りましょう。LINEとの疎通確認が目的です。

今回のような規模のお試し開発では、Herokuは非常に良いデプロイサーバです。先ほどの図で示したように、GitHubとの連携設定を行うことで、masterブランチにプッシュした場合即座にデプロイしてくれる点が非常に助かりました。AWSに展開することも考えたのですが、すぐ試したいときはHeorkuの方が良いです。さて、ここでは以下の順で作業を進めていきます。

  • PlayアプリのGitHubリポジトリとHerokuを連携
  • Herokuでアドオンを導入(Postgresql, Fixie)
  • LINEのコールバック先・ホワイトリストを設定
  • BOTプログラムを作成
  • プッシュ&デプロイ

GitHubリポジトリとHerokuを連携

Herokuの画面に従い、GitHubと連携します。これで、masterブランチにpushされるたびに、Herokuのサーバへデプロイされるようになります。

HerokuとGitHubの連携

PostgresqlとFixieを導入

PostgresqlはToodledoから取得するアクセストークンを保存するために、FixieはデプロイサーバのIPアドレスを固定して LINE BOT のアクセス許可サーバIPを設定するために利用します。どちらも、それほどアクセスが発生しない状況下では無料の範囲内で利用できます。

Heroku Postgres

heroku postgres

Fixie

fixie

Fixieを利用するためには、LINE BOT に固定したIPアドレスをセットすることに加え、LINEに対してリクエストを送るときにプロキシサーバを経由させる必要があります。後述します。

LINEのコールバック先、サーバホワイトリストを設定

Herokuの画面から得られる情報をLINE側へ設定します。このとき、コールバック予定のパスを予め決めておきます。今回は/lineとしました。

コールバックURLの設定

さらに、Fixieを使ってIPを固定しますので、Fixieの画面「Outbound IPs」よりIPアドレスを取得し、LINE側へ設定します。

ホワイトIPリストの設定

BOTプログラムを作成

買い物リストのことは考えず、固定文言を返すプログラムを作成します。Scalaのクラス構造を大まかに以下のようにしています。

Scalaクラス構造

  • ① CallbackController: LINE BOT に対するメッセージのコールバックを扱うコントローラです。BOTへのすべてのメッセージはここに到達します。メッセージの中身を見てどのサービスを呼ぶか決めます。今回はConstantMessageService固定です。
  • ② ConstantMessageService: メッセージを受け取り、どのような返信をするか決めるレイヤです。いわゆるビジネスロジックをここで決めることになります。今回は固定文言を生成するのみです。serviceレイヤはLINEへの返信のやり方は知りません。「具体的には何をやってるか知らないけれどLINEに返信してくれるLineMessageSenderトレイトを呼ぶ」ことが仕事です。
  • ③ MessageSendApiClient: LineMessageSenderトレイトを実装するクラスです。送り先や返信メッセージのフォーマットを知っており、serviceから受け取ったコンテンツをJSONに変換して実際にLINEへ送ります。LineMessageSenderはDIによってMessageSendApiClientという具体的なインスタンスを作ることになります。
CallbackController
class CallbackControllerForBlog @Inject()(
    constantService: ConstantMessageService // -- ①
) extends Controller {

  import domains.line.LineConverters._

  def callback = Action.async(parse.json) { req =>
    for {
      received <- FutureSupport.jsResultToFuture(req.body.validate[ReceiveMessage]) // -- ②
      constantService.send(received)
    } yield NoContent
  }
}
  • ① : ConstantMessageServiceをDIします。
  • ② : コールバックで受け取ったリクエストボディJsonを我々のバリューオブジェクトに変換してあげます。Jsonには、誰から送られてきたか、や、どんなメッセージが送られてきたか、が含まれています。
ConstantMessageService
class ConstantMessageServiceForBlog @Inject()(
    sender: LineMessageSender
) {

  def send(receive: ReceiveMessage): Future[Unit] =
    sender.send(SendMessage(List(receive.result.head.from), "なにいってだこいつ"))

}

具体的にどうやってLINEに送るのかは知らないけれど、何を送ればよいかは知っているので、固定文言をLineMessageSenderに渡しています。お察しのとおり、このBOTは何を送っても「なにいってだこいつ」としか返さない失礼極まりないBOTと相成ります。

LineMessageSender, MessageSendApiClient
trait LineMessageSender {

  def send(message: SendMessage): Future[Unit]

}
class MessageSendApiClient @Inject()(
    ws: WSClient
) extends LineMessageSender {

  import SendMessageConverter._

  // -- ①
  val Url = "https://trialbot-api.line.me/v1/events"
  val EventType = "138311608800106203"
  val ToChannel = 1383378250


  // -- ②
  val proxyUrl: URL = new URL(System.getenv("FIXIE_URL"))
  val host = proxyUrl.getHost
  val port = proxyUrl.getPort
  val protocol = proxyUrl.getProtocol
  val userInfo = proxyUrl.getUserInfo
  val user = userInfo.substring(0, userInfo.indexOf(':'))
  val password = userInfo.substring(userInfo.indexOf(':') + 1)
  val encodedAuth = new BASE64Encoder().encode(userInfo.getBytes())
  val Proxy = DefaultWSProxyServer(host, port, Some(protocol), Some(user), Some(password))

  // -- ③
  val XLineChannelID = System.getenv("X_LINE_CHANNEL_ID")
  val XLineChannelSecret = System.getenv("X_LINE_CHANNEL_SECRET")
  val XLineTrustedUserWithACL = System.getenv("X_LINE_TRUSTED_USER_WITH_ACL")


  override def send(message: SendMessage): Future[Unit] = {
    val request = ws.url(Url)
        .withProxyServer(Proxy)
        .withHeaders(
          "Content-Type" -> "application/json; charset=UTF-8",
          "X-Line-ChannelID" -> XLineChannelID,
          "X-Line-ChannelSecret" -> XLineChannelSecret,
          "X-Line-Trusted-User-With-ACL" -> XLineTrustedUserWithACL,
          "Proxy-Authorization" -> s"Basic $encodedAuth"
        )
    val body = Json.toJson(toSendMessageJsonObject(message))
    request.post(body).map(_ => ())
  }

  // -- ④
  private def toSendMessageJsonObject(sendMessage: SendMessage): SendMessageJsonObject =
  SendMessageJsonObject(
    to = sendMessage.toList,
    toChannel = ToChannel,
    eventType = EventType,
    Content(
      contentType = 1,
      toType = 1,
      text = sendMessage.message
    )
  )
}
  • ① : LINEのマニュアルから指定されたお決まりの文字列です。
  • ② : IPを固定するFixieサーバのプロキシ設定です。Fixieのページマニュアルから、これらのコードを生成することができます。
  • ③ : Herokuに設定した環境変数を読み込んでいます。これらの値は LINE Buisiness Center から取得することができます。
  • ④ : serviceで生成したメッセージ情報から、LINEに送るためのオブジェクトを作ります。このオブジェクトをJSONに変換してあげることでLINEへのリクエストボディを生成することができます。

プッシュ&デプロイ

masterブランチにプッシュすると、自動でHerokuに展開されます。試してみましょう。

固定文言のやりとりをしているLINE BOT

ちょっと腹が立ちますが無事メッセージを受け取ることができました。次はToodledoからタスクを取得します。

2. PlayアプリからToodledoのAPIを呼び出して、タスク一覧を取得する

これまでの手順でPlayアプリとLINEを連携することができました。原理としては、固定文言を返している部分を買い物リストにしてやればやりたいことが実現できそうです。次は、ToodledoのAPIを呼び出して買い物リストを取得してみましょう。Toodledo API は、ざっくり以下の手続きを踏むことでタスク管理ができるようになります。

  • アプリケーション登録
  • 認証、アクセストークン取得
  • タスクリストを取得

順番に見ていきましょう。

アプリケーションの登録

http://api.toodledo.com/3/
手順に従いアプリケーションを登録します。

Developer's API Documentation : Version 3.0

認証、アクセストークン取得

OAuth認証を使っていますので、アプリケーションの認証を経てアクセストークンが発行されます。ここでは発行されたアクセストークンをPostgresqlに保存することがゴールとなるのですが、まずはアプリケーション認証が行われた後のリダイレクト先を用意してあげる必要があります。これもHerokuにデプロイしているアプリを使うこととし、リダイレクト先として/tasks/authエンドポイントを用意します。リダイレクトされ受けとった情報を使って、アクセストークンを取得、Slick経由でPostgresqlに保存します。認証について本家の手順が最もわかりやすいです。

タスクリストを取得

取得したアクセストークンを使って、こんどはタスクリストを取得します。Toodledoはいわゆる検索APIほど高機能ではなく、細かいパラメータ指定などがありません。条件にあてはまるタスクは全部返すからあとはクライアント側でよしなにやってね、という程度です。ゆえにリクエストパラメータもそこまで複雑ではありません。

class ShoppingTaskApiClient @Inject()(
    dbConfigProvider: DatabaseConfigProvider,
    wSClient: WSClient
) extends ShoppingTaskRepository {

  import ResponseConverters._

  val dbConfig = dbConfigProvider.get[JdbcProfile]
  val ShoppingContextId = 1162707L

  private def getTaskUrl(accessToken: String): String =
    s"""http://api.toodledo.com/3/tasks/get.php
        |?access_token=$accessToken
        |&comp=0
        |&fields=folder,star,priority,context""".stripMargin.replaceAll("\\n", "")


  override def list(): Future[List[Task]] = for {
    resultAuth <- dbConfig.db.run(Tables.AuthTest.filter(_.appName === "toodledo").result) // -- ①
    tasksJson <- wSClient.url(getTaskUrl(resultAuth.head.accessToken.get)).get() // -- ②
    result <- FutureSupport.jsResultToFuture(tasksJson.json.validate[List[JsValue]])
    tasks <- FutureSupport.jsResultListToFuture(result.drop(1).map(f => f.validate[Task])) // -- ③
  } yield tasks.filter(p => p.contextId == ShoppingContextId) // -- ④
}
  • ① : 保存したアクセストークンを取得します。
  • ② : アクセストークンとともにタスク取得リクエストを行います。
  • ③ : タスクリストのレスポンスがArray Jsonで返ってくるのですが、Arrayの先頭はメタ情報であるため除去しています。その上でアプリが解釈できるオブジェクトに変換しています。
  • ④ : 私はコンテキストというToodledoの機能を使って買い物リストを管理していますので、買い物リストに相当するコンテキストIDだけをフィルタします。

3. Toodledoから抽出したタスクリストをLINEメッセージにはめこむ

1でつくった単純なBOTのメッセージに、2で抽出したタスクリストを埋め込むようなイメージです。「買い物リストを取得する機能」も、「LINEに任意のメッセージを送る機能」も作成しましたので、あとはこれらを統合してやるサービスをひとつ作ってあげれば良さそうです。図では以下のようなイメージ。

買い物リスト取得のクラス図

  • ① : CallBackControllerから新しいサービスであるShoppingListReplyServiceをよぶようにします。
  • ② : ShoppingListReplyServiceの仕事は、「Toodledoから買い物リストを取得」して、「買い物リストをLINEのメッセージとして送信する」です。
  • ③ : トレイトであるShoppingTaskRepositoryShoppingTaskApiClientが具体化します。
  • ④ : トレイトであるLineMessageSenderMessageSendApiClientが具体化します。固定文言を返すところで示したものと全く同一です。渡すメッセージが変わっているだけです。

ShoppingListReplyServiceの実装を見てみます。

class ShoppingListReplyService @Inject()(
    shoppingTaskRepository: ShoppingTaskRepository,
    sender: LineMessageSender
) {

  def send(receive: ReceiveMessage): Future[String] = {
    val head = receive.result.head
    for {
      tasks <- shoppingTaskRepository.list()
      shoppingListString: String = tasks.map(_.title).mkString("\n")
      _ <- sender.send(toSendMessage(head.from, shoppingListString))
    } yield shoppingListString

  }

  private def toSendMessage(to: String, message: String): SendMessage =
    SendMessage(List(to), message)

}

sendメソッドのfor文に注目してください。記述内容はとてもシンプルで、買い物リストを取得した後、タイトルを取り出して文字列に変換します。それをそのままLINEへ送るメッセージとして指定しているだけです。先ほどは固定文言でしたが、今回は買い物リストにしていますので、これで買い物リストが取得できるようになるはずです。

4. LINEのメッセージを「タスク閲覧」と「タスク追加」に振り分ける

先で作ったプログラムは「タスクリストの抽出」ですが、同じようにToodledoのタスク追加APIを呼ぶことで、買い物リストの追加も行えます。LINE BOT が「買い物」というメッセージを受け取ったら買い物リストを取得し、「買い物 XXX」というようにスペースを開けて何かしら文字列が続く場合はXXXを新しい買い物タスクとしてToodledoに登録するよう処理を振り分けてみましょう。OperationFileterというObjectを用意して、LINEから送られてきたメッセージによって処理を決定するヘルパー関数を作ります。

object OperationFilter {
  val R = """(.+)\p{javaWhitespace}(.+)""".r

  def operationIs(message: ReceiveMessage): LineOperation = message.result.head.text match {
    case R("買い物", target) => AppendShoppingTaskList(target) // -- ①
    case "買い物" => GetShoppingTaskList // -- ②
    case _ => Other
  }
}
  • ① : 「買い物 (空白) {文字列}」というメッセージだった場合、買い物リストを追加するコマンドだと解釈します。
  • ② : 「買い物」というメッセージだった場合、買い物リストを取得するコマンドだと解釈します。

あとは、CallbackController でこの関数を呼び出して、これまで実装してきたサービスを呼び分けることによって、送られてきたメッセージに対して適切な処理を行うことができるようになります。なお、新しく買い物リストを追加するサービスとしてShoppingListAppendServiceを実装しました。

class CallbackController @Inject()(
    shoppingReader: ShoppingListReplyService,
    shoppingAppender: ShoppingListAppendService,
    constantService: ConstantMessageService
) extends Controller {

  import domains.line.LineConverters._

  def callback = Action.async(parse.json) { req =>
    for {
      received <- FutureSupport.jsResultToFuture(req.body.validate[ReceiveMessage])
    } yield {
      OperationFilter.operationIs(received) match {
        case GetShoppingTaskList => shoppingReader.send(received) // -- ①
        case AppendShoppingTaskList(newTask) => shoppingAppender.append(received, newTask) // -- ②
        case Other => constantService.send(received) // -- ③
      }
      Logger.info(OperationFilter.operationIs(received).toString)
      NoContent
    }
  }
}
  • ① : 買い物リスト取得の場合はShoppingListReplyServiceを利用。
  • ② : 買い物リスト追加の場合はShoppingListAppendServiceを利用。
  • ③ : それ以外の場合は固定文言を返すConstantMessageServiceを利用。

完成です。masterブランチにプッシュしましょう。デプロイされるのを待ちます。

5. 試す

  • 左:「買い物」と打つ
  • 中:「買い物 自転車」と打つ
  • 右:「めそ子 年齢」と打つ

結果画像3点セット

おわりに

サーバサイドアプリを作成することで、LINEから買い物リストを管理できるようになりました。日常で誰かとメッセージのやりとりをしているとき、ついでにBOTに聞いて買い物リストをみることで、記憶だけが頼りだった状況から少し改善できたと感じています。

世の中には多くのサービスがあり、それをすべて使いこなすのはとても大変です。マイクロサービスアーキテクチャをはじめ、特定のソリューションに特化したサービスが様々な箇所に散在する状況では、サーバサイドアプリによる統合はひとつの回答といえます。Toodledoも本当に使いこなそうと思うと多くの機能が用意されているのですが、「買い物リスト」と要件を絞ることで、BOTから使えるレベルにはシンプルになっています。必要なドメインに特化(買い物リスト)し、シンプル化(タスクタイトルの取得と追加のみ)、そして統合(LINE上でやりとり)という一連の流れが、今後の世の中では求められる考え方になると思います。

参考