Pano Blog

【Slack Bot開発】HubotとBoltを使用して感じたことまとめ

December 11, 2021 , posted under slack bot

この記事は CAMPHOR- Advent Calendar 2021 の11日目の記事です。

本記事ではHubotとBoltの比較について書こうと思います。(思想が違うものを比較するなという点は一旦置いておいておきます笑)

僕はつい最近Bot開発やChatOpsを知ったばかりなので、普段から開発しておられる方には当たり前の情報かもしれません。

当初はCAMPHOR-で開発しているBotをHubotからBoltに移行したという話をしようと思ったのですが、色々検討することや(僕が)開発をさぼっていたこともあり終わっていないのでHubotとBoltの現状とか書いてみた感触をざっくりとまとめてみようと思います。

それでは早速行きます。最初に簡単にHubotとBoltの説明をします。

【注意】 本記事の内容はあくまで主観であり、また使用経験も少ないため誤った情報を含んでいる可能性があります。

HubotとBolt

Hubot:

GitHub製のBotフレームワーク。CoffeeScriptで記述する。

Bolt:

Slack製のSlackアプリケーションフレームワーク。Java, Python, JavaScript(TypeScript)に対応している。

基本的な書き方

ひとまず一番基本的な「特定の文字列を送るとBotがメッセージを送信する」処理を書いてみます。

helloという文字列(大文字・小文字を区別しない)が投稿されたときにHello!とメッセージをチャンネルに送る記述は次のようになります。

Hubot

robot.respond /hello/i, (msg) ->
  msg.reply "Hello!"

Bolt

app.message(/hello/i, async ({ say }) => {
  await say("Hello!")
})

Hubotの方はCoffeeScriptで書かれているので一見違うように見えますが、robot.respondapp.message も第一引数にパターン(マッチさせる文字列や正規表現)を、第二引数にコールバック関数(Boltではこれをリスナー関数と呼ぶ)を受け取っています。

他にも robot.errorapp.errorrobot.enterapp.event('member_joined_channel', fn) なども同じ形であり、いずれも第一引数にパターン、第二引数にコールバック関数を取る形をしています。

Hubot と Bolt(bolt-js) の対比はこちらにまとめられていますが、ほとんど違和感なく書き換えられる気がします。

メソッドの差異

正直ほとんど似たような名前のメソッドにより置き換えが可能で大差ないのですが、フレームワークの微妙な差として「sendとsayの違い」「hubotのrandomメソッド」を例に挙げます。

またその際、わかりやすくするためにHubotのコードはJavaScriptで示します。 CoffeeScriptはJavaScriptにコンパイルされる言語で、最初からJavaScriptで書くことができます。

send と say の違い

例えばHubotで以下のようなコードを書いたとします。

robot.hear(/ping/i, (res) => {
  res.send('called')
})

これをBoltで書いたとすると

app.message(/ping/i, async ({ say }) => {
  await say('called')
})

と書くことになると思います。

Hubotのコードは例えばチャンネルに「ping」とメッセージを投稿すると同じチャンネルに「called」というメッセージを送信し、スレッドにして「ping」と打つと同じスレッドにさらに続けて「called」と返します。

しかしBoltのコードはチャンネルに送ったものも、スレッドにぶら下げて送ったものもどちらに対してもチャンネルにメッセージを送信します。

なのでもしsendと同じことをBoltでやろうとすると以下のような実装になります。

app.message(/ping/i, async ({ client, message, say }) => {
  if (message.thread_ts?) {
    const options = {
      channel: message.channel,
      text: 'called',
      thread_ts: message.thread_ts ?,
    }

    await client.chat.postMessage(options)
  } else {
    await say('called')
  }
})

簡単に説明をすると、まず message.thread_tsundefined でないか(値が入っているか)を確認します。

もし値があったら options という 送信先(channel), 送信内容(text), スレッドのタイムスタンプ(thread_ts) を指定したオブジェクトを代入して client.chat.postMessage() の引数として実行します。

もし値がなかったら say() を実行してチャンネルに送ります。

(ちなみにスレッドのタイムスタンプというのはスレッドの一番先頭の投稿のタイムスタンプのことです。 メッセージにユニークなIDとかが振られているわけではなく、この辺はタイムスタンプで管理してるみたいです。)

もちろんHubotでも自分でsendの対象を内部的な値から切り替える場合にはmessage.thread_tsを書き換えたりするので、その場合には似たような処理が必要ですが、それでもsendの方が channelclient.chat.postMessage() 辺りが隠蔽されています。

※注意: Hubotでそのような指定ができないわけではありません。直接channelを書き換えたり、robot.adapter.client.web.chat.postMessage()を使えば同じようにchannel等も内部的な値から変更できます。しかしHubotの方がよく使うケースは簡潔にかけるように隠蔽されているということです。

randomメソッド

Hubotでいくつかの選択肢の中からランダムに返信しようとすると以下のように記述すると思います。

robot.hear(/おみくじ|運|運試し/, (msg) => {
  msg.send(
    msg.random([
      "大吉",
      "中吉",
      "小吉",
      "吉",
      "末吉",
      "凶",
      "大凶",
    ])
  )
})

一方でBoltにはrandom関数のような便利メソッドはありません。 なので以下のように自力で randList[Math.floor(Math.random() * randList.length)] のようにする必要があります。

const randList = [
  "大吉",
  "中吉",
  "小吉",
  "吉",
  "末吉",
  "凶",
  "大凶",
]

app.message(/おみくじ|運|運試し/, async ({ say }) => {
  const result = randList[Math.floor(Math.random() * randList.length)];
  await say(result);
});

こちらも大したことではないので気になりませんが、やはりHubotの方がよく使われるケースは内部で実装されているケースが多く、Boltの方がSlack APIの仕様がより前面にでてきているように思います。

HubotにはないBoltの強み

先ほどまではHubotにもBoltにもある機能を比較しましたが、今度はBoltだけにある強みをいくつか紹介します。

Hubotにはない機能

ボタンやメニュー選択などのインタラクティブな機能が使用できます。

Boltではメッセージ送信時にコンテキストアクションを含めることができます。

またこれはフレームワークそのものの特徴ですがSlack APIの仕様に則った細かい調整は先ほど見た通り、(Hubotでもできますが)最初からその書き方をするBoltの方が書きやすいかもしれません。

そもそもBoltはSlackアプリケーションフレームワークで、HubotはBotフレームワークでした。HubotはSlackに限らずAdapterが作られているものすべてで動かせるというものなのでそこは当たり前と言えば当たり前かもしれません。

開発言語

これは一番最初にも書きましたがHubotはCoffeeScript(JavaScriptでも可)、BoltはJava, Python, JavaScript(TypeScript)に対応しています。

Hubotの方もJavaScriptが可能ということはTypeScriptをコンパイルしたJavaScriptを使えるので、TypeScriptも可能ということになります。

しかしHubotは実質JavaScript一択なのに対して、Boltはその他にJavaとPythonで書けるのはかなり大きいと思います。

(※ どちらもTypeScriptで書けるとしましたが、実際は少し問題があるので後に記述します。)

フレームワーク自体の開発

Hubotはどうやらv3.3.2をリリースした2019年4月17日で開発が止まっているようです。 理由はよくわかりません。

一方でBoltは2021年11月3日にリリースされたv3.8.1が最新で現在も開発が続いています。

しかしHubotのSlackのアダプターはまだ普通に開発が続いているようなので、仕様変更でHubotがいきなり使えなくなることはない(?)と思われます。

TypeScriptでの開発について

最後にBoltとHubotでのTypeScriptでの開発について書こうと思います。

BoltをTypeScriptで書く

ドキュメントの中やリポジトリでもTypeScriptを利用するとSlack APIの型情報が利用できるということが書かれています。 例えばTypeScriptでの利用ガイド辺りにも最新の @slack/boltTypeScript 4.1以上 での利用をサポートしていると書かれています。

しかし実はこの記事の一番冒頭に書いた、「いろいろ検討することがあったり、開発をさぼっていた」原因はこれになります。 結論から言うと、現状のBoltをTypeScriptで書くのはそこまで簡単ではないと思います。

例えばこちらのissueには以下のように問題提起されています。

Bolt's current TypeScript implementation serves to slow down rather than speed up the developer experience. 
There is little useful documentation to help developers who wish to write Slack apps in TypeScript.

Boltの現在のTypeScriptの実装は、開発者の体験を速めるどころか遅らせる役割を果たしています。
SlackアプリをTypeScriptで書きたいと思っている開発者に役立つドキュメントはほとんどありません。

それを受けて、リポジトリのexampleコードのgetting-started-typescriptにはこう書かれました。

This is a temporary example app intending to show how to work with Bolt's type system.
Because the type system is currently lacking, this app will change in the future. See #826 for more context.

これは、Boltのタイプシステムをどのように使うかを示すための一時的なサンプルアプリです。
型システムは現在不足しているので、このアプリは将来的に変更されるでしょう。詳細は#826を参照してください。

(#826は先ほどのissue)

実は私もいくつかのサンプルがそのままだと動かないということに気付き、該当箇所を見に行ったのですがTypeScriptと型力が弱くて何が問題なのかわかりませんでした。

そして一旦インターンなどで放置していて戻ってきたときに先ほどのIssueを発見し(遅い)、どうするのが良いかと思って開発が止まっていました。

具体例としてIssueで挙げられている例を示そうと思います。

app.message(':wave:', async ({ message, say }) => {
  await say(`Hello, <@${message.user}>`);
});

このコードは message.user の部分で

Property 'user' does not exist on type 'KnownEventFromType<"message">'.

と怒られます。

実はこれは先ほどリンクを貼ったドキュメントの一番最初の例になります。

Issueを立てた方は

... and feels more like an exercise in reverse engineering the SDK than it does in creating awesome new Slack apps. 

現状のBolt AppをTypeScriptで記述するのは新しいSlackアプリを作るというよりも、SDKをリバースエンジニアリングする練習のようだ。

と表現しています。

しかし現在はこのIssueは閉じられており、議論から(僕の英語力が間違っていなければ)この問題に対応していくという意思を感じました。

ちなみに上記の問題を現状で回避するには次のように message の型をキャストする必要があるみたいです。

app.message(':wave:', async ({ message, say }) => {
  await say(`Hello, <@${(message as GenericMessageEvent).user}>`);
});

※この方法を提示すると共に開発者の方は「言うまでもなくこれは素晴らしいことではない」と付け加えている。

つまり、「きちんとSlack APIの仕様を理解した上で、要所要所で適切な型にキャストできれば書ける」ということな気がします。

では一方でHubotならどうなんだというのを次に書こうと思います。

HubotをTypeScriptで書く

こちらは楽天さんの技術記事HubotをTypeScriptで書くがわかりやすい気がするのでそちらを。

今回私たちが気にしている部分に関してだけ言うと次のような感じです。

@types/hubot を使うことで Hubot の記述に型を付ける。

しかし hubot-slack (これは先ほども出てきたHubotでSlackを使うためのアダプター)の robot.adapter.client.web.chat.postMessage() の部分で hubot-slack に型情報がないことでコンパイルが通らず、robot: any にせざるを得なかった。

つまり Hubot でも同じということですね…

まとめ

(今回はCAMPHOR- Botの移行の話はできませんでしたが) 実は移行しようとしていた理由はいろいろありますが、最大の理由は静的型付け(TypeScript)が欲しかったからです。

つまり調査してみた結果、「うーむ、どうしよう」となりました。

とりあえず

  • 上記のIssueの議論以降でどの程度改善や計画が進んだのか(軽く試した感じは前回詰まったところは同じ状況でした)
  • この規模程度であれば辛さよりも静的型付けのメリットを取れるか
  • そもそもTypeScriptを最も重視するなら他の選択肢もあるのか

等を検討しようと考えています。

しかしやはりTypeScriptの良いところとしてJavaScriptのコードを部分的にリプレイスできるところがあるので、Boltの静的型付け以外の部分の利点を生かして一旦リプレイスして、その後段階的TypeScript化するなどもあるかなと考えています。

(そもそも一旦リファクタやCI/CD環境の変化への追従もしたかったので)

ということでまとめます。

Hubot: 簡単なコマンドはSlack APIの存在を隠蔽したまま書ける、がなぜか開発が止まっている。かなりの規模なのと、Botフレームワークの最強格の1つなので仮にもし完全に開発が止まってもfork先でまた栄えるかもしれない。

Bolt: Slack謹製のSlackアプリケーションフレームワーク。Slackに関してはHubotよりも多くの機能を扱え、開発も継続されている。しかしTypeScriptでの開発はまだしんどい(多分)。

筆者はこのリプレイスのためにドキュメントをちょっと読んで、ちょっと書いただけのSlack Bot初心者なので、現在のBot開発事情やChatOps、上記の問題を改善する方法等ご存知の方いらっしゃったら教えていただけると幸いです。