Slack App を作るフレームワークのboltを検証する

slack.dev

今回は Bolt ネタです。
今まで、Slack Bot を作成する時は howdyai/botkit を使ってました。
これ自体はよくできていて、良いのですが、 Slackのためのというわけではなく Facebook Messanger 上で動作させたりと結構汎用的に作られている framework なのでコード読んだりする時にちょっと苦労してました。

一方 Boltは Slack で作られた Slack のための Framework なので使ってみようと思い今回は botkit からのリプレイスをするために必要な機能の検証を行いました。

検証項目

結論

今回検証項目に出していたことは全部満たしていたので、既存の botkit で作成した Slack App はリプレイスしていこうと思います。

検証環境

  • bolt: v1.4.0

まず共通して必要な App の initilize をしておきます。
SLACK_BOT_TOKEN や SLACK_SIGING_SECRET はあらかじめ自分のワークスペースで作成しておきます。

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

slash command は使えるのか

これは使えました。
下記のコードを実装することで /greeting とおくると @hatappi Hello!! と返ってきて、 /greeting おはよう と送ると @hatappi おはよう が Slack に投稿されます。

app.command('/greeting', async ({ command, ack, say }) => {
  ack();
  const msg = command.text || "Hello!!";
  say(`<@${command.user_id}> ${msg}`);
});

Block Kit は使えるか

Block Kit 使えました。
さっきの greeting command のレスポンスを Block Kit で返してみます。
コードは↓のコードを使いました。

app.command("/greeting", async ({ command, ack, say }) => {
  ack();

  const msg = command.text || "Hello!!";
  say({
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `<@${command.user_id}> ${msg} \n今日ご飯いきませんか?`
        }
      },
      {
        type: "actions",
        elements: [
          {
            type: "button",
            text: {
              type: "plain_text",
              emoji: true,
              text: "いく"
            },
            style: "primary",
            value: "yes",
            action_id: "btn_yes"
          },
          {
            type: "button",
            text: {
              type: "plain_text",
              emoji: true,
              text: "いかない"
            },
            style: "danger",
            value: "no",
            action_id: "btn_no"
          }
        ]
      }
    ]
  });
});

すると /greeting のようなコマンドを Slack 上で入力すると↓のような Block Kit で指定されたレスポンスが返ってきます。

f:id:hatappi1225:20191020163449p:plain

Block Kit のレスポンスを構築する時は Block Kit Builder を使いながら構築するとはかどります。

ただここで表示するボタンはクリックしてもその動作を定義していないので何も起きません。

button を出してクリックしたら何かを返すインタラクティブなことはできるか

さきほどは ボタンを出したので次はそのボタンを押した時に何かを返す動作を定義します。

app.action("btn_no", async ({body, ack, say }) => {
  ack();
  say(`<@${body.user.id}> :cry:`);
});

app.action("btn_yes", async ({body, ack, say }) => {
  ack();
  say(`<@${body.user.id}> :+1:`);
});

Block Kit でボタンを作成した時に action_id を指定しましたが、それを指定してそれぞれどんな動きをするかを定義しています。
また app.action の第1引数には文字列だけでなく正規表現も書くことができるので先ほどの例の場合だと次のように書くこともできます。

app.action(/btn_[yes|no]/, async ({body, ack, say }) => {
  ack();
  const val = body.actions[0].value;
  const reaction = (val === "yes") ? "+1" : "cry"
  say(`<@${body.user.id}> :${reaction}:`);
});

modal を出すことはできるか

次はモーダルを出してみます。
先ほどの いく というボタンを押した時にモーダルを出してみます。

app.action("btn_yes", async ({body, ack, context }) => {
  console.log(body);
  ack();
     app.client.views.open({
      token: context.botToken,
      trigger_id: body.trigger_id,
      view: {
        type: "modal",
        callback_id: "order_foods",
        title: {
          type: "plain_text",
          text: "注文"
        },
        blocks: [
          {
            type: "input",
            label: {
              type: "plain_text",
              text: "何を注文しますか?"
            },
            element: {
              type: "plain_text_input",
              action_id: "foods",
              multiline: true
            }
          },
        ],
        submit: {
          type: "plain_text",
          text: "注文する"
        }
      }
    });
});

これで次のようなモーダルが開きます。

f:id:hatappi1225:20191020171032p:plain

後は注文するボタンを押した時の動作を指定します。

app.view("order_foods", async ({ ack, body, view, context }) => {
  ack();

  const user = body.user.id;
  const order = view.state.values.order.foods.value;

  app.client.chat.postMessage({
    token: context.botToken,
    channel: user,
    text: `${order} で注文した`
  });
});

これで Bot からのメッセージとして入力したものが表示されているはずです。

マルチプラットフォームとして提供できそうか

とここまではだいたいドキュメントに書いてあるものを組み合わせればできます。

slack.dev

ここからは複数のプラットフォームに提供するために必要な認証周りを試していきます。
ドキュメントのほうには複数の認証情報をもっている時にどうやって切り替えるかはのっていますが、そもそもどうやって複数の認証情報、つまる異なるワークスペースから認証情報を受け取るかが書いていないようだったので今回はそれを実装してみました。
https://slack.dev/bolt/ja-jp/concepts#authorization

App をインストールした時に認証情報を取得するためのプロセスですが、OAuth 認証を使っていきます。

api.slack.com

例えば今回は /oauth に callback させることを想定して、 App の OAuth & Permissions -> Redirect URLshttps://example.com/oauth のように指定します。
example.com は自分のドメインに置き換えます。

コード側の実装ですが、 Bolt は App を initilize する時に receiver というものがわたせます。
https://github.com/slackapi/bolt/blob/master/src/App.ts#L54 これはSlackからのイベントを受け取るサーバーを構築する部分?でデフォルトだと express ベースになっています。
これを少し拡張して callback 先を実装していきます。

デフォルトの挙動はそのまま使いたいのでデフォルトで指定されている ExpresReceiver から拡張していきます。

const { App, ExpressReceiver } = require("@slack/bolt");

const expressReceiver = new ExpressReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET
});

const app = new App({
  receiver: expressReceiver
});

まずはこれで準備が整いました。
ここから callback 先を指定していくのですが、例えばシンプルな例だと OK だけを返すような healtcheck 用の GET リクエストのエンドポイントを作ってみます。

expressReceiver.app.get("/health", (_req, res) => {
  res.status(200).send("OK");
});

これだけです。
では callback 先を実装していきます。

expressReceiver.app.get("/oauth", async (req, res) => {
  const data = await app.client.oauth.access({
    client_id: process.env.SLACK_CLIENT_ID,
    client_secret: process.env.SLACK_CLIENT_SECRET,
    code: req.query.code
  });

  const botInfo = await app.client.users.info({
    token: data.bot.bot_access_token,
    user: data.bot.bot_user_id
  });

  auth[data.team_id] = {
    botToken: data.bot.bot_access_token,
    botUserId: data.bot.bot_user_id,
    botId: botInfo.user.profile.bot_id
  };

  res.status(201).send("OK");
});

API には2回問い合わせをしていて 1回目は callback でパラメータについてきた code から Slack のチーム情報や Botトークンなどの各種情報を取得します。
2回目の API では bot の id を取り出すために user 情報 API をたたいています。

今回は auth という変数にいれたのでサーバーを再起動したらインストールしなおしが必要なので実際に作る時は各種DBとかに情報をいれる必要があります。

これを取り出す部分はドキュメントにも書いてあるのを参考にして簡略化して書くと↓のような感じになります。
https://slack.dev/bolt/ja-jp/concepts#authorization

const authorizeFn = async ({ teamId }) => {
  return auth[teamId];
};
const app = new App({
  authorize: authorizeFn,
  receiver: expressReceiver
});

最後に

今回は色々検証のために構築してみましたが bolt 良い感じ!なので今後使っていきたい。