hacomono TECH BLOG

フィットネスクラブ・スクールなど施設・店舗のための会員管理・予約・決済システム「hacomono」 開発チームの技術ブログ

Slack App で簡易ワークフローを作る

こんにちは。大阪でエンジニアをしている和田です。

先日とある要件で、EC2 を起動するためのワークフローを作成しました。
最終的なアウトプットは次のようなイメージです。

  1. コマンド実行でモーダルが立ち上がる。

  2. 入力内容が承認ボタンつきで投稿される

  3. ボタン押下で対象EC2が起動する。また押下できるユーザーは限定する。

今回の記事ではつまづきそうなポイントを補足しつつ、簡単なアプリケーションの開発の流れを解説していきます。


アプリ開発の流れ

Slack App の作成

まずは slack api 上でアプリを作成していきます。
今回は scratch で開発していきます。

次に OAuth & Permissions > Scopes より必要な権限を必要に応じて設定していきます。

  • chat:write … メッセージの投稿のため
  • users.profile:read ... ユーザー詳細情報取得 (/api/users.profile.get) のため
  • users:read … ユーザー情報取得 (/api/users.info) のため

設定が終わったら Install to Workspace を押下することで Bot User OAuth Token が発行されます。

次に Slash Commands より起動コマンドを定義するのですが、その前に Socket Mode メニューより Socket Mode を有効化しておきます。
これにより、app からのAPI の受け口がまだできていなくてもローカルで開発が可能になります。

有効化するとまたトークンが発行されますが、こちらは Basic Information > App-Level Tokens からも確認ができます。

以降の開発やアプリの実行には、ここまで発行された2つのトークンと Basic Information > App Credentials > Signing Secret を加えた3つのトークンがそれぞれ必要になってきます。

Socket Mode を有効にしたあと、Slash Commands より起動コマンドを定義しましょう。

コマンドはあとからでも変更できるので、適当に決めていきます。

Bolt を使用してアプリを開発する

ここからは Bolt を利用して実装を進めていきます。ちなみにここまでの内容の大体はリンク先のドキュメントに書いてあります。

この記事では Javascript (node.js >= 18) を利用して開発を進めますが他にも Python や Java が利用可能です。

準備

node.js がインストールされてなければインストールのうえ、適当にプロジェクト用のディレクトリを用意しプロジェクトを作成していきます。

$ mkdir project_name
$ cd project_name
$ npm init
$ npm install @slack/bolt

app.js を用意しサンプルコードを実装していきます。

const { App } = require('@slack/bolt');

// ボットトークンと Signing Secret を使ってアプリを初期化します
const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET
});

(async () => {
  // アプリを起動します
  await app.start(process.env.PORT || 3000);

  console.log('⚡️ Bolt app is running!');
})();

各ページからトークンを取得、環境変数に設定の上 app.js を起動します。
app.js

$ export SLACK_BOT_TOKEN={Basic Information > App Credentials > Signing Secret}
$ export SLACK_SIGNING_SECRET={OAuth & Permissions}
$ node app.js
⚡️ Bolt app is running!

ここまでできたら、次はコマンドを受けられるようにコードを修正します。
app.js

const { App } = require('@slack/bolt');

// ボットトークンと Signing Secret を使ってアプリを初期化します
const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  socketMode: true, // 追加: Socket Modeを有効にします
  appToken: process.env.SLACK_APP_TOKEN, // 追加: APPトークンを設定します
});

// "command_test" コマンドをリッスンします
app.command('/command_test', async ({ command, client }) => {
  // イベントがトリガーされたチャンネルにメッセージを送信します
  try {
    await client.chat.postMessage({
      channel: command.channel_id,
      text: 'Hello, World!'
    });
  } catch (error) {
      console.error(error);
  }
});

(async () => {
  // アプリを起動します
  await app.start(process.env.PORT || 3000);

  console.log('⚡️ Bolt app is running!');
})();

ここで、Slack の任意のチャンネルに app を追加しておきましょう。
app にメンションを送るか、チャンネル設定の Integrations > Add apps から追加できるかと思います。

追加後、今度は appToken も環境変数に指定した上で再度アプリを起動します。

$ export SLACK_APP_TOKEN={Basic Information > App-Level Tokens}
$ node app.js
[INFO]  socket-mode:SocketModeClient:0 Going to establish a new connection to Slack ...
⚡️ Bolt app is running!
[INFO]  socket-mode:SocketModeClient:0 Now connected to Slack

socket mode での接続が行われることが確認できたら、アプリが追加されているチャンネルでコマンドを実行してみます。

メッセージが返ってくれば成功です。

Serverless Framework を利用してデプロイ & API を作成する

作成したアプリケーションをAPI化するには様々な手段があるかと思いますが、今回は公式に用意されたドキュメントに沿って Lambda へデプロイしていきたいと思います。

Slack → API Gateway → Lambda というかたちで処理を流し、Lambda から 今回の目的である EC2 の起動を行います。

aws-cli のインストールや設定が前提とはなりますが、このドキュメントでは割愛させていただきます。

デプロイする

まずは serverless をインストールします。ローカルインストールでも構いません。

$ npm install -g serverless

serverless コマンドで serverless 用の雛形プロジェクトを用意することもできるのですが、今回はserverless.yml をプロジェクトルートに配置することで済ませたいと思います。

service: test-bolt-app
frameworkVersion: '3'
provider:
  name: aws
  runtime: nodejs18.x
  region: ap-northeast-1
  environment:
    SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
    SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN}
functions:
  slack:
    handler: app.handler
    events:
      - http:
          path: slack/events
          method: post

次に、app.js を Lambda 用に書き換えていきます。
app の生成部分と、Lambda 用の handler を公開します。

const { App, AwsLambdaReceiver } = require('@slack/bolt');

// Initialize your custom receiver
const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
});

// Initializes your app with your bot token and the AWS Lambda ready receiver
const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  receiver: awsLambdaReceiver
});

~~~~~

// for socket mode
// (async () => {
//   // アプリを起動します
//   await app.start(process.env.PORT || 3000);

//   console.log('⚡️ Bolt app is running!');
// })();

// for Lambda
module.exports.handler = async (event, context, callback) => {
  const handler = await awsLambdaReceiver.start();
  return handler(event, context, callback);
}

ここまでできたらデプロイを実施していきます。

aws-cli の準備と、API Gateway や Lambda や作成できる credentials が設定されていることが前提となります。

$ serverless deploy
Running "serverless" from node_modules

Deploying nwada-test-bolt-app to stage dev (ap-northeast-1)

✔ Service deployed to stack nwada-test-bolt-app-dev (102s)

endpoint: POST - https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/slack/events

成功すると endpoint がプロンプトに通知されます。
リソースが作成されていることも確認できます。

endpoint を app に設定する

Socket Mode より Socket モードを OFF にします。

このとき、Command に Request URL を設定していない場合、設定を促されるため先程のendpoint を RequestURL に指定します。

ここまでできたら疎通確認を行います。
再度 app をインストールしたチャンネルでコマンドを叩いてみます。

無事に結果が返ってくればよいのですが、うまくいかなければデバッグの始まりです 😇
CloudWatch にログが吐かれるようになっていますので、そこから辿っていきましょう。ログが吐かれていなければその手前で問題が発生していると考えます。

幸い自分は軽微なミスを犯しているだけで、ログからすぐに解決ができたのでウキウキで記事を書き進めます。

serverless-layers で軽量化する

出来上がったLambda を参照すると、数MB の zip がデプロイされているかたちでありコードの参照ができない状態かと思います。

serverless-layers プラグインを利用することで node_module 部分をレイヤ化し、パッケージサイズの削減に加えてAPI のレイテンシーにも寄与することができます。

執筆時点では 2.8 系が最新なのですが、不具合があるようでうまく動作させることができなかったため、一つ前のマイナーバージョンを利用します。

$ serverless plugin install --name serverless-layers@2.7.0

次に severless.yml の設定を変更します。

レイヤをアップロードするための s3 バケットを作成の上、provider 項へ以下のとおり追加します。

provider:
  deploymentBucket:
    name: 'nwada-test-bolt-app'

# 以下は自動で追加される
plugins:
  - serverless-layers

設定完了後、デプロイを実施します。

うまく行けばコンソール上から Lambda のソースコードが参照でき、レイヤが追加されていることも確認できるかと思います。再度疎通の確認をしておきましょう。

また、不要なファイルはアップロードされないように除外設定も付け加えておきます。

package:
  patterns:
    - '!node_modules/**'
    - '!package.json'
    - '!package-lock.json'
    - '!README.md'

コードサイズをここまで削減することができました。

アプリケーションを作り込む

ここからは細かいことは ChatGPT などに任せるほうが早いかと思うので、ポイントだけかいつまんで説明していきます。

モーダルからの送信や、ボタンへのリアクションを受けるためにはSlack の設定が追加で必要となるため、 Interactivity & Shortcuts のページから Interactivity を ON にします。

Request URL にはデプロイの際に取得した endpoint を設定しておきます。

コマンドを受け付け、モーダルを表示する

app.command でコマンドを受け付け、リスナー関数内で引数として渡された client を利用してモーダルを表示します。

ここではモーダルで送信された内容をもとに、チャンネルをメッセージとして投稿したいために private_metadata へ command が実行されたチャンネルのIDを渡しておくのがポイントです。

app.command('/command_name', async ({ command, ack, body, client, logger }) => {
  await ack();
  await client.views.open({
    trigger_id: body.trigger_id,
    view: {
      type: 'modal',
      callback_id: 'callback_id',
      private_metadata: `${command.channel_id}`,
      title: {
        type: 'plain_text',
        text: 'title'
      },
      blocks: [
        ...
モーダルの送信内容を受け取り、メッセージを投稿する

app.view でモーダルからの送信をリッスンしておきます。

private_metadata からチャンネル ID を、view.state.values から送信された内容を取り出し、メッセージを組み立てます。

app.view('callback_id', async ({ ack, body, view, client, logger }) => {
  await ack();
  const channelId = view.private_metadata;    
  await client.chat.postMessage({
    channel: channelId,
    text: msg,
    blocks: [
      ...
      ,
      {
        type: "actions",
        block_id: 'block_id'
        elements: [
          {
            type: "button",
            text: {
              type: "plain_text",
              text: "承認"
            },
            action_id: 'action_id'
          },

なお、モーダルやメッセージのデザインを行っていく際は Block Kit Builder を活用することで効率的に開発が行えます。

投稿されたメッセージのボタンにリアクションする

app.action でアクションをリッスンします。

今回は何回もボタンが押されないように、当該ブロックを適当な内容で置き換えてメッセージを更新することでボタンを消す、という手段を取りました。

app.action('action_id', async ({ ack, body, client }) => {
  await ack();
  
  // client を利用し slack api を実行することも可能です。
  // ここで OAuth の scopes が必要になってきます
  const userId = body.user.id
  const info = await client.users.info({user: userId});

  ...

  const newBlocks = body.message.blocks.map((block) => {
    if (block.block_id === BLOCK_ID_BUTTON) {
      return {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `承認されました`
        }
      };
    } else {
      return block;
    }
  });
  
  await client.chat.update({
    channel: body.channel.id,
    ts: body.message.ts,
    blocks: newBlocks
  });
});
IAM の設定

Serverless Framework では標準で IAM ポリシーの記述もできるようになっています。
Lambda に与える必要のある権限を以下のような形式で記載していきます。

今回は EC2 の起動や停止などを実施するための権限を以下のように与えました。(Resource など、絞れるものがあれば絞りましょう。)

provider:
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - 'ec2:DescribeInstances'
            - 'ec2:StartInstances'
            - 'ec2:StopInstances'
          Resource: '*'


おわりに

Slack App によって、Slack 上でかなり複雑なことまで実現可能になるかと思いますが、少々とっつきづらさもあるかと思います。

これから Slack App でなにか作ろうと考えている方の参考になれば幸いです。


株式会社hacomonoでは一緒に働く仲間を募集しています。
採用情報や採用ウィッシュリストもぜひご覧ください!