役に立たないチャットボット2020 〜あの社内ボットは今……そして slack-wrench〜

※ この記事は VOYAGE GROUP Techlog Advent Calendar 2020 15 日目の記事です。

こんにちは!fluct でインターネット広告配信のお手伝いをしている @jewel_x12 です。今日は社内チャットボットの話を書いていきます。

VOYAGE GROUP は 2014 年から Slack を使用していて、コロナ禍における在宅勤務で重要な役割を果たしています。その社内 Slack ワークスペースでは 2014 年から今日に至るまで jewelpet というボットが元気に動いています。

f:id:jewel12:20201216111127p:plain

mint.hateblo.jp

あの社内ボットは今……

Slack 使い始めの頃の jewelpet の様子は

  • 機能実装の方針
    • 有用なものを極力作らない
    • かわいいバグは放置する
  • ボットのフレームワーク: Hubot
  • 使用言語: CoffeeScript
  • コード管理とデプロイ: jewelpet の作者に Direct Message でコードを送るとマージしてデプロイしてくれる(古風)

という感じでした。機能実装の方針を見てもらえば分かるのですが、jewelpet は結構ゆるいボットで、便利に使ってもらうと言うよりも社内コミュニケーション促進の立ち位置が強いです。

そんなこんなで jewelpet は今も動いているのですが、この 7 年間、同じ構成でずっと動いていたわけではなく、Slack が公式に提供している Bolt へ 2019 年に移行しています。 移行理由は関連ライブラリのバージョン管理をしっかりしておらず、あるライブラリがバージョンアップしたときにライブラリの依存解決が難しくなり、デプロイ不能になったことでした。ボットの方針だけでなく、システム運用もゆるっとしてたんですね。

移行してからはこういう構成になりました。

  • 機能実装の方針は変わらず
  • ボットのフレームワーク: Bolt
  • 使用言語: TypeScript
  • コード管理とデプロイ: GitHub でコード管理・メインブランチへマージするとデプロイされる

おお、少しはナウくなりました!

コード管理が GitHub になったのが大きいですね。今や社員みんなが jewelpet の作者になったわけです*1

この移行に関してはもっと詳しいスライドがあるので、興味のある方はご覧になってください。

speakerdeck.com

ボットの動作テストと slack-wrench

さて、クリスマスカラーといえばレッドとグリーン、レッドとグリーンといえばテストです。誰でも jewelpet の作者になれる時代がやってきたのですが、動作テストをしづらいという声を聞くようになりました。ある言葉に反応するような機能を作ったとき、デプロイして Slack から入力するまで本当に動くか分からない状態だったんですね。トライアンドエラーしにくいので、動作テストをもう少し簡単にしたい。

Bolt の動作テストパターンとしては ngrok を使ったものがあります。ngrok はローカルマシンのポートに対して外部からアクセスできる URL を作れます。ローカルマシンで Bolt を起動したあと Slack のイベント通知先を ngrok で作った URL に向けることで、Slack アプリから動作テストできます。

slack.dev

このやり方は End to End のテストとしては本番環境に近くて良い方法なんですが、ngrok を無料で使う分には URL の固定ができないので Slack アプリケーション設定がテストの度に必要です。また、Slack アプリから入力する手間もあります。

理想的には Hubot Shell adaptor のようにローカルマシンからメッセージを入力して、出力を見れると良いのですが、Bolt にはそのような機能がありません。Bolt は起動すると Events API を listen するモードに入るので、ダミーイベントを Bolt へ通知することができれば……と考えていたところ、まさにそのようなことをしているツールを見つけました。

github.com

slack-wrench は Slack のテストに便利なパッケージ群で、IBM さんから提供されています。今回、主に使うパッケージは jest-bolt-receiverjest-mock-web-client になります。これらを使うと下記のように、Bolt アプリケーションに対する入出力のテストを Jest で書けます。

describe("ジュエルペット", (): void => {
    it("返事をする", async () => {
        await receiver.send(events.message("ジュエルペット"))
        expect(botMessages()[0]).toBe("なんや")
    })

    it("メッセージ中にジュエルペットが含まれていても返事をする", async () => {
        await receiver.send(events.message("吾輩はジュエルペットである"))
        expect(botMessages()[0]).toBe("なんや")
    })
})

Hubot Shell adaptor のようにしたかった意図とはずれますが、テストをコードに落とし込むことで何度も入力しないで済みますね。

Bolt は Events API を受け付ける部分(receiver)を変更できるのですが、任意のイベントを送信できるようにした receiver が jest-bolt-receiver になります。イベントの構築には fixtures を使うと便利です。 receiver の差し替えは以下のようなコードでやっています。

import JestRceiver from '@slack-wrench/jest-bolt-receiver'
import {
    MockedWebClient,
    MockWebClient,
  } from '@slack-wrench/jest-mock-web-client'
import { App } from '@slack/bolt'

import { setup, Config } from "../app"

export const receiver = new JestRceiver()

export const botUserId = "JEWELPET"
const app = new App({receiver, token: '', botUserId: botUserId, botId: "ジュエルペット"})
const conf: Config = {}
setup(app, conf) // リスナー等を設定する

export const client = MockedWebClient.mock.instances[0]

jest-mock-web-client は Bolt 内で Slack へのメッセージ送信を司るクライアントを Manual Mock するもので、例えばこのクライアントがどう呼び出されているかを見ることで、ボットから送信されるメッセージを確認することができます。メッセージを得る部分は以下のようなヘルパーを用意しました。

export const botMessages = (): string[] => {
    return client.chat.postMessage.mock.calls.map(arg => arg[0].text)
}

テスト例

Jest で入出力のテストができるようになりました!

これで有休が大量にあるか確認するコマンドをテストできるようになったし

describe("jewcan_holidays", (): void => {
    it("有休があること", async () => {
        await receiver.send(slashCommand("/jewcan_holidays"))
        const msgs = botMessages()
        expect(msgs.length).toBe(2)
        expect(msgs[0]).toMatch(/有休残確認してきます$/)
        expect(msgs[1]).toMatch(/有休残日数は\d{3,}日です/) // 有休は3桁以上あること。たくさんあったほうが嬉しい。
    })
})

昔からあったかわいいバグがちゃんと実装されているかも確認できます!便利だ!やったー!

describe("タイ料理屋", (): void => {
    it("おすすめしてくれる", async () => {
        await receiver.send(events.message("<@JEWELPET> タイ"))
        expect(botMessages()[0]).toMatch(/.+に行くパカ$/)
    })

    it("タイマーを使いたいときもおすすめしてくれる(旧バージョンのバグの再現)", async () => {
        await receiver.send(events.message("<@JEWELPET> タイマー"))
        expect(botMessages()[0]).toMatch(/.+に行くパカ$/)
    })
}) 

まとめ

VOYAGE GROUP は社内コミュニケーションツールとして Slack を利用しています。コミュニケーションを(たぶん)支えているボットは今も健在で、入出力のテストができるようになったりと開発しやすくなりました。これからも役に立たない機能をどんどん追加していきたいです!

この記事を読んで、役に立たないボットを交えつつ働いてみたい!という方がいらっしゃいましたら、VOYAGE GROUP では仲間を募集しているので、是非声をかけてください!

hrmos.co

voyagegroup.com

*1:理解にコンテキストを要する文。名前は似てるけど jewelpet の作者は僕ではなく、僕の知人で詳細は不明という設定になっていました。これはボットが失礼な発言をしても怒られの対象をウヤムヤにするプラクティスです。(ボットが変なこと言った程度では怒られるような雰囲気じゃないですが)