🤖

Block Kit + Bolt で宣言的UIを実現!Slack ボットの参加者募集機能を作ろう

2023/03/04に公開

概要

次の画像のようなイベントの参加者を募り、参加者がボタンをクリックすると誰が参加するかが表示される Slack ボットを例に Block Kit + Bolt で宣言的 UI で実現する方法を紹介します。

前提知識

Block Kit とは

Slack の Block Kit はリッチな UI の Slack ボットを作成するのに使用するライブラリです。
JavaScript/TypeScript で Block Kit を使うには jsx-slackslack-block-builder が便利です。

https://api.slack.com/block-kit

画像の UI が

次のような JSON で実現できます。

{
	"blocks": [
		{
			"text": {
				"type": "mrkdwn",
				"text": "*草むしり検定* への参加者募集中です!"
			},
			"type": "section"
		},
		{
			"type": "divider"
		},
		{
			"elements": [
				{
					"text": {
						"type": "plain_text",
						"text": "参加する"
					},
					"action_id": "participate",
					"type": "button"
				}
			],
			"type": "actions"
		}
	]
}

プレビュー

Bolt

Slack の Bolt は「ユーザーのボタンの押下に応じて何かをする」ようなインタラクティブなボットの作成に使用するライブラリです。

https://slack.dev/bolt-js/ja-jp/tutorial/getting-started

次のようなコードを書くことで Slack 上の各イベントを listen することができます。

import bolt, { BlockAction, ButtonAction } from "@slack/bolt";
const { App } = bolt;

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  socketMode: true,
  appToken: process.env.SLACK_APP_TOKEN,
});

async function main() {
  await app.start(process.env.PORT || 3000);
}

main();

app.action<BlockAction<ButtonAction>>(
  "actionId",
  async ({ body, ack }) => {
    // actionId が付与されたボタンがクリックされると呼び出される関数
    console.log(body);
    await ack();
  }
);

動的な UI を持つ Slack ボットを開発するつらさ

概要で説明したようなボットは UI を動的に変更する必要があります。
動的に変更するには chat.update を使用して Block Kit の blocks を書き換える必要があります。
具体的には、先程のコードだと次のようになります。

app.action<BlockAction<ButtonAction>>(
  "actionId",
  async ({ body, ack }) => {
    // actionId が付与されたボタンがクリックされると呼び出される関数
    console.log(body);
    await ack();
    
    const blocks = body.message.blocks;
    const ts = body.message.ts;
    const channel = body.channel.id;
    
    // ここで blocks を変更する
    // blocks[2] にある「参加する」ボタンを削除
    blocks.splice(2, 1);
    // blocks[2] に「hoge さんが参加者です」テキストを挿入
    blocks.push({...});
    
    // blocks を更新する
    await client.chat.update({
      blocks,
      channel,
      ts,
    });
  }
);

blocks オブジェクトを命令的に書き換える必要があります。これは blocks の要素の並び順の変更があると意図しない UI を削除してしまいます。変更に弱い実装方針は採用したくありません。

宣言的 UI

React を始めとするモダンフロントエンドのライブラリ/フレームワークは宣言的に UI を定義できます。
命令的に DOM を書き換えるのではなくて、ほしい DOM の形を宣言してそこに状態を流し込むイメージです。

今回はこの考えを Slack ボットに適用しました。body.message.blocks の構造を把握して、部分的に中身を置き換えるのではなくて、新しい状態に対して最初から blocks を生成してすべてを置き換えるアプローチです。

宣言的にボットの UI を作成する

slack-block-builder を例に実例を紹介していきます。

blocks を返す関数を定義する

まず最初にボットの UI の blocks を返す関数を定義します。

import { Message, Blocks, Elements, conditionals } from "slack-block-builder";

export type Props = {
  eventName: string;
  participant?: string;
};

export const CallForParticipantBlock = ({ eventName, participant }: Props) =>
  Message()
    .text("参加者募集中")
    .blocks(
      Blocks.Section().text(`*${eventName}* への参加者募集中です!`),
      Blocks.Divider(),
      conditionals.setIfTruthy(participant, [
        Blocks.Section().text(`${participant} さんが参加者です`),
      ]),
      conditionals.setIfFalsy(participant, [
        Blocks.Actions().elements(
          Elements.Button().text("参加する").actionId("participate")
        ),
      ])
    );

CallForParticipantBlock を呼び出すことで次のような UI を定義できます。

プレビュー

blocks を生成する引数を chat.postMessageの metadata に含める

Slack の chat.postMessage API には任意のデータを格納できる metadata というパラメータがあります。これに blocks の生成に必要な引数(React でいう state)を設定します。

import "dotenv/config";
import { WebClient } from "@slack/web-api";
import {
  CallForParticipantBlock,
  Props,
} from "./call-for-participant-block.js";

const token = process.env.SLACK_BOT_TOKEN;
const channel = process.env.SLACK_CHANNEL;

const webClient = new WebClient(token);

async function main() {
  const state: Props = {
    eventName: "草むしり検定",
  };
  CallForParticipantBlock(state).printPreviewUrl();
  await webClient.chat.postMessage({
    ...CallForParticipantBlock(state).channel(channel).buildToObject(),
    // chat.postMessage 時に metadata を設定する
    metadata: { event_type: "state", event_payload: state },
    icon_emoji: ":robot_face:",
    username: "募集bot",
  });
}

main();

metadata を変更して、blocks を返す関数にわたす

chat.postMessage で設定した metadata はユーザーのインタラクションがあったときに発火する app.action で参照することができます。
具体的には body.message.metadata.event_payload にアクセスすると metadata を取得できます。

これを踏まえて、metadata にある state を変更しつつ、新しい blocks を生成するコードは次のようになります。


app.action<BlockAction<ButtonAction>>(
  "participate",
  async ({ body, payload, ack, client }) => {
    await ack();

    if (!body.message) {
      throw new Error("No message");
    }
    if (!body.channel) {
      throw new Error("No channel");
    }

    // mutate state
    const state: Props = {
      ...body.message.metadata.event_payload,
      // metadata(staet) の participant だけを更新する
      participant: body.user.name,
    };

    const ts = body.message.ts;

    await client.chat.update({
      // 更新後の state を使って blocks を再度生成する
      ...CallForParticipantBlock(state).buildToObject(),
      channel: body.channel.id,
      ts,
      metadata: { event_type: "state", event_payload: state },
    });
  }
);

state は単なるオブジェクトなので、開発者はオブジェクトに対する mutation を意識するだけで済みます。なので、「state を mutation するロジック」と「state から blocks を生成するロジック」を独立に実装やテストすることが可能になり、開発もしやすくなります。

終わりに

chat.postMessage の metadata を使うことで Block Kit を使った Slack ボットを宣言的に実装する例を紹介しました。

この記事の全体のコードは GitHub にあります。

https://github.com/odan-sandbox/slack-block-kit-sandbox

Discussion