開発のためにBoltでSlackボットを作った話

SREの菅原です。 この記事はカンム Advent Calendar 2022の2日目の記事になります。

細々としたことをさせるためのボットをSlackに常駐させるのはよく行われていることだと思いますが、カンムにも kanmukun と  kabot という2台のボットが常駐しています。

私の入社当時 kanmukun しかいなかったのですが、あまりメンテナンスされている様子がなく、新しい機能を追加するのが難しかったため、新しく kabot というボットを作成しました。

ボットを作成するにあたって既存のSlackボットフレームワークを利用することを検討しましたが、Hubotはすでにメンテナンスが止まっており、RubotyはカンムにはRubyの文化がないため自分以外が触るのが難しく、となかなかしっくりくるフレームワークは見つからず…

いろいろと調べた結果、最終的にはSlackのソケットモード+Boltでボットを実装することしました。JavaScriptで書き始めましたが、その後同僚のアドバイスを受けてTypeScriptで書き直しました。

kabotの実装

ファイルの構成はこんな感じです。

.
├── Dockerfile
├── Makefile
├── README.md
├── manifest.dev.yml
├── manifest.yml
├── package-lock.json
├── package.json
├── dist/
├── node_modules/
├── src
│   ├── action.ts
│   ├── actions
│   │   ├── asg.ts
│   │   ├── assign.ts
│   │   ├── book.ts
│   │   ├── github-zen.ts
│   │   ├── help.ts
│   │   ├── image.ts
│   │   ├── index.ts
│   │   ├── issue.ts
│   │   ├── omikuji.ts
│   │   ├── oyatsu.ts
│   │   ├── ping.ts
│   │   ...
│   ├── aws.ts
│   ├── github.ts
│   ├── handler.ts
│   ├── index.ts
│   ├── reaction.ts
│   ├── reaction_handler.ts
│   └── reactions
│       ├── index.ts
│       ├── infra_ticket.ts
│       ...
└── tsconfig.json

Handlerクラスがメッセージを受け付けて、マッチするコマンドがあれば関連付けられたアクションを呼び出します。

app.event("app_mention", async (message) => {
  const { say } = message;

  try {
    await Handler.call(message);
    // ...(略)

export class Handler {
  static async call(message: Message) {
    const { event, context, say } = message;
    const text = event.text
      .replace(new RegExp(`^<@${context.botUserId}>\\s+`), "")
      .trim();

    let matched = false;

    for (const [key, action] of this.actions) {
      const matchData = text.match(key);

      if (matchData) {
        matched = true;
        await action.call(matchData, message);
      }
    }
class Ping implements Action {
  name = "ping";
  help = "Return PONG to PING";

  async call(_m: RegExpMatchArray, { body, say }: Message) {
    say({
      text: `<@${body.event.user}> pong`,
      thread_ts: body.event.thread_ts,
    });
  }
}

Rubotyの実装に倣っていて、BrainとAdapterのないHubotのような感じです。

ボットの運用については、ちょうど去年作った内部サービス向けECS環境があったので、そこで動かしています。

kabotの機能

個人的に暇を見つけては、開発で役に立つ機能からどうでも良い機能までちまちまとkabotに追加しています。

Slackユーザーグループからランダム選択

リリース用PRの作成

*1

Issueの作成

PagerDutyのオーバーライド

社員情報の表示

画像検索

Spotify検索

Youtube検索

ニコニコ検索

書籍検索

…等々、他にもいろいろなコマンドを実装しています。

リアクションへの反応

@kabot [コマンド] でkabotにコマンドを実行される以外に、リアクションの絵文字にも反応できるようにしています。

以下はリアクションに反応してIssueを作成している様子です。

実装としては、reaction_addedイベントをフックしてHandlerクラスと同様にマッチしたコマンドを呼び出すようにしています。

app.event("reaction_added", async (message) => {
  if (message.payload.item.type != "message") {
    return;
  }

  try {
    await ReactionHandler.call(message);
    // ...(略)

export class ReactionHandler {
  static async call(message: Message) {
    const { client, payload, event } = message;
    const { channel, ts } = event.item as { channel: string; ts: string };

    for (const [key, reaction] of this.reactions) {
      if (message.event.reaction == key) {
        const ms = await client.conversations
          .replies({
            channel: channel,
            ts: ts,
          })
          .then((h) => h.messages);

        if (ms && ms[0]) {
          await reaction.call(message, ms[0]);
        }

SlackでちょっとつぶやいたことをそのままGitHubのIssueにできるのが便利です。

まとめ

かなりぱぱっと作ったものではあるのですが、pr-releaseコマンドとissueリアクションはそれなりに利用されていて、DXの改善に貢献できていると思います。また、コマンドをサクッと追加できるようにしているので、仕事の気晴らしにくだらないコマンドを追加しするのも楽しいです。

最近はSlackボットにメンションするよりスラッシュコマンドを実行するのほうがメジャーなのかなと思いつつも、@kabot pingと打ってpongと返してくれるのは可愛く思ってしまうので、今後もメンテしていきたいと思っています。

*1:git-pr-releaseを使っています