16
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Slack ワークフローのカスタムステップで「承認ボタン付きメッセージ」を送る

Last updated at Posted at 2025-12-15

Slack ワークフローのカスタムステップを使って、

  • 入力に応じて「ボタン付きメッセージ」または「通常メッセージ」を送信し
  • ボタンの結果に応じてワークフローを継続 or 終了する

という処理を実装したときのメモです。

公式ドキュメントやブログ記事は増えてきましたが、「実際のコード全体」と「ハマりどころ」がまとまっている記事が少なかったので、自分が詰まったポイントも含めて整理しました。

  • Slack 側:カスタムステップを含むワークフロー
  • 実装側:AWS Lambda(+ Lambda Function URL)
  • 言語:JavaScript(Node.js)

「とりあえず動くところまで持っていきたい」「GAS とどっちを選ぶか悩んでいる」方の参考になればうれしいです。

ゴールと要件

ゴール

既存の Slack ワークフローに「カスタムステップ」を追加し、次のような動きを実現します。

  • ワークフローから渡される input に応じて
    • ボタン付きメッセージを送る
    • または、ボタンなしメッセージを送ってそのまま次のステップへ進める
  • ボタン付きメッセージの場合
    • OK ボタン → ワークフローを次のステップへ進める
    • NO ボタン → その場でワークフローを終了する

前提・制約

  • 最近リリースされた「条件分岐(Branch)」機能は、弊社のプランでは利用不可
    • プラン体系が新しくなり、一番高いプランでないと使えないとのこと
  • そのため、カスタムステップ+ボタンを使って「なんちゃって承認フロー」を作る、という方針にしました。

全体構成のイメージ

この記事で扱う構成はざっくり次のとおりです。

  1. Slack アプリ(カスタムステップ付き)を作成

  2. Lambda(1 つのエンドポイント)で

    • Challenge(url_verification)
    • カスタムステップからの呼び出し(function_executed)
    • インタラクション(ボタン押下)

    を全部受ける

  3. event.type / body の中身を見て、処理を振り分ける

  4. 最終的に functions.completeSuccess / functions.completeError でワークフローに結果を返す

以降では、

  • まず Slack アプリ側の準備
  • 次に Lambda 側のコードとロジック
  • 最後にワークフロー側の設定と、GAS を諦めた理由

の順で書いていきます。

1. Slack アプリの準備

基本的には、以下の Qiita 記事に沿って進めれば問題ありません。

このとき注意したポイントは次の 2 つです。

  • エンドポイント URL(Request URL / Interactivity URL など)は、Lambda 側の準備が終わらないと確定できない
  • 記事の「手順 6 以降」(Event Subscriptions, Interactivity, Workflow Steps)は、本記事の「3. ワークフロー側の準備」と一緒にやったほうが流れがスムーズ

この記事では、Slack アプリの細かい設定手順は省略し、ハマりどころになりがちな Lambda 側の実装 にフォーカスします。

2. Lambda 側の実装

2-0. ここが一番しんどい

なぜか。

参考になる「フルコード付き」の記事がほとんどないから!

公式ドキュメントと AI とヘルプデスクまでフル活用して作りましたが、

  • カスタムステップ自体がまだ新しめ
  • Slack のイベント種別ごとに body の構造が変わる
  • それらが全部「同じエンドポイントに飛んでくる」

という事情もあって、理解するまでかなり苦戦しました。

ここからは、実際に動かしたコードの形をベースに説明していきます。

2-1. 事前準備(import と環境変数)

import querystring from 'querystring'
import crypto from 'crypto';

const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN;
const SLACK_CHANNEL_ID = process.env.SLACK_CHANNEL_ID;
const SLACK_API_URL = 'https://slack.com/api/'

2-2. 単一エンドポイントでのハンドラ構成

Lambda には原則 1 つの Function URL を設定し、そこにすべてのリクエストを集約しています。

  • url_verification(Challenge)
  • ワークフローからの呼び出し(function_executed)
  • ボタン押下などのインタラクション(block_actions)

を、1 つの handler の中で振り分けるイメージです。

// Lambda entry
export const handler = async (event) => {
  // body を取り出す
  const rawBody = event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString('utf8') : event.body;

  // ボタンが押されたときの処理
  if (rawBody.startsWith('payload=')) {
    const params = querystring.parse(rawBody);
    const payload = JSON.parse(params.payload);
    if (payload.type === 'block_actions') {
      await handleInteraction(payload);
      return {
        statusCode: 200,
        body: ''
      };
    }
  }

  // ボタンじゃない時のインプットを定義
  const data = JSON.parse(rawBody);

  // ワークフローからの呼び出し
  if (data.event && data.event.type === 'function_executed') {
    await handleFunctionExecuted(data.event);
    return {
      statusCode: 200,
      body: JSON.stringify({ ok: true })
    };
  }
};

この時点だとまだ Challenge(url_verification)には対応していないので、次で追加していきます。

2-3. Challenge(url_verification)対応と署名検証

まず、「そもそも Challenge とは?」ですが、公式ではこう説明されています。

エンドポイントの疎通を確認するために、Slack が type: url_verification のリクエストを送り、あなたのサーバーが challenge の値をそのまま返すことで、URL の所有を証明する仕組み

参考: url_verification event | Slack Developer Docs

今回は、Event Subscriptions の Request URL として Lambda Function URL を指定しています。
Lambda Function URL はパブリックに公開する必要があります。
もしセキュリティ的に不安があれば、Signing Secret を使った署名検証を入れておくとよいです。
ということで、署名検証を含めた handler の最終形は次のようになりました。

// Lambda entry
export const handler = async (event) => {
  // 署名検証
  if (!verifySlackSignature(event)) {
    return {
      statusCode: 401,
      body: 'Unauthorized' 
    };
  }

  // body を取り出す
  const rawBody = event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString('utf8') : event.body;

  // ボタンが押されたときの処理
  if (rawBody.startsWith('payload=')) {
    const params = querystring.parse(rawBody);
    const payload = JSON.parse(params.payload);
    if (payload.type === 'block_actions') {
      await handleInteraction(payload);
      return {
        statusCode: 200,
        body: ''
      };
    }
  }

  // ボタンじゃない時のインプットを定義
  const data = JSON.parse(rawBody);

  // チャレンジレスポンス(SlackアプリのURL承認用)
  if (data.challenge) {
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'text/plain'
      },
      body: data.challenge
      };
  }

  // ワークフローからの呼び出し
  if (data.event && data.event.type === 'function_executed') {
    await handleFunctionExecuted(data.event);
    return {
      statusCode: 200,
      body: JSON.stringify({ ok: true })
    };
  }
};


// Slack署名を検証する関数
function verifySlackSignature(event) {
  const signingSecret = process.env.SLACK_SIGNING_SECRET;
  const timestamp = event.headers['x-slack-request-timestamp'];
  const slackSignature = event.headers['x-slack-signature'];
  
  // ★ Base64エンコードされている場合はデコードする
  const body = event.isBase64Encoded 
  ? Buffer.from(event.body, 'base64').toString('utf-8') 
  : event.body;

  // 1. タイムスタンプ検証(5分以上古いリクエストは拒否 → リプレイ攻撃対策)
  const currentTime = Math.floor(Date.now() / 1000);
  if (Math.abs(currentTime - timestamp) > 300) {
    console.error('Request timestamp is too old');
    return false;
  }

  // 2. 署名を計算
  const sigBaseString = `v0:${timestamp}:${body}`;
  const mySignature = 'v0=' + crypto
    .createHmac('sha256', signingSecret)
    .update(sigBaseString, 'utf8')
    .digest('hex');

  // 3. 署名を比較(タイミング攻撃対策)
  try {
    return crypto.timingSafeEqual(
      Buffer.from(mySignature, 'utf8'),
      Buffer.from(slackSignature, 'utf8')
    );
  } catch (e) {
    return false;
  }
}

※ 署名検証に関しては下記の記事を参考にさせていただいたので、こちらもご覧ください。
(参考:https://cly7796.net/blog/other/validate-requests-from-slack-using-signing-secret/

2-4. カスタムステップのメイン処理(引数の受け渡し)

カスタムステップを使うメリットの 1 つは、ワークフロー内で扱った値(inputs)を、そのままカスタムステップに渡せる点です。

実際の event の中身を見てみると、event.inputs に入力値が入っていました。

今回は、ワークフロー側で WithButton というフラグを用意し、

  • WithButton = true → ボタン付きメッセージを送る
  • WithButton = false → ボタンなしでワークフローを続行

という分岐にしています。
あわせて、後続のステップで使う function_execution_id も取得しておきます。
これは、ワークフローの実行ごとに発行される識別番号です。

// ワークフロー実行時の処理
async function handleFunctionExecuted(event) {
  const function_execution_id = event.function_execution_id;
  const with_button = event.inputs.WithButton;

  if (with_button) {
    // ボタンつきメッセージを送る
    await sendMessage(function_execution_id);
  } else {
    // ワークフロー続行
    await completeSuccess(function_execution_id, { approved: true });
  }
}

ここで sendMessagefunction_execution_id を渡しておくと、ボタン押下時にワークフローを再開させるときにも便利です。

2-5. ボタン付きメッセージとワークフロー完了通知

次に、Slackにメッセージを送る処理と、ボタン押下時の処理です。

// ボタン付きメッセージを送る
async function sendMessage(function_execution_id) {
  const buttonValue = JSON.stringify({
    function_execution_id: function_execution_id
  });

  await callSlackApi('chat.postMessage', {
    channel: SLACK_CHANNEL_ID,
    text: '承認してください',
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `問題なければOKボタンを押して下さい!`
        }
      },
      {
        type: 'actions',
        elements: [
          {
            type: 'button',
            text: {
              type: 'plain_text',
              text: 'OK'
            },
            style: 'primary',
            value: buttonValue,
            action_id: 'ok_action'
          },
          {
            type: 'button',
            text: {
              type: 'plain_text',
              text: 'NO'
            },
            style: 'danger',
            value: buttonValue,
            action_id: 'no_action'
          }
        ]
      }
    ]
  });
}

// ボタン押下ハンドラ
async function handleInteraction(payload) {
  const action = payload.actions[0];
  const value = JSON.parse(action.value);
  const function_execution_id = value.function_execution_id;
  const messageTs = payload.container.message_ts;

  const approved = action.action_id === 'ok_action';
  const newText = approved ? '承認されました。' : '却下されました。';

  // メッセージ更新(ボタンを消して結果表示)
  await callSlackApi('chat.update', {
    channel: SLACK_CHANNEL_ID,
    ts: messageTs,
    text: newText,
    blocks: [{ type: 'section', text: { type: 'mrkdwn', text: newText } }]
  });

  // ワークフロー完了通知
  if (approved) {
    await completeSuccess(function_execution_id, { approved: true });
  } else {
    await completeError(function_execution_id, '申請が却下されました');
  }
}

// ワークフロー成功通知
async function completeSuccess(function_execution_id, outputs = {}) {
  await callSlackApi('functions.completeSuccess', { function_execution_id, outputs });
}

// ワークフローエラー通知
async function completeError(function_execution_id, error) {
  await callSlackApi('functions.completeError', { function_execution_id, error });
}

// Slack API 呼び出し(最小)
async function callSlackApi(endpoint, payload) {
  const res = await fetch(SLACK_API_URL + endpoint, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${SLACK_BOT_TOKEN}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(payload)
  });
  return res.json();
}

ポイントは次のあたりです。

  • valuefunction_execution_id を入れておくことで、ボタン押下時にどのワークフローの実行(execution)に対応するか判断できる
  • ボタン押下後は chat.update でメッセージを更新し、ボタンを消すと見た目がすっきりする
  • functions.completeSuccess / functions.completeError を呼ぶことで、ワークフロー側に結果が返り、次のステップ or 終了に進む

3. ワークフロー側の準備

ここからは、Slack ワークフロー側の設定です。
冒頭で紹介した Qiita 記事の「手順6以降」に対応する部分です。

3-1. Slack アプリの設定(Event / Interactivity)

  • Event Subscriptions
    • Request URL に Lambda Function URL を設定
    • url_verification を通す(上記のChallenge)
  • Interactivity & Shortcuts
    • Interactivity を有効化
    • Request URL に同じ Lambda Function URL を設定

3-2. ワークフローの設定(カスタムステップ)

ワークフロー側では、ざっくり次のような構成にしています。

  1. トリガー(フォーム入力など)
  2. 入力結果を使いつつ、WithButton などのフラグを決める
  3. カスタムステップ(今回作った Slack アプリ)を実行
  4. functions.completeSuccess の結果をもって、後続ステップへ

今回便宜的にWithButtonという変数を使用しましたが、フォームで収集できる要素なら何でも渡すことができるので、ニーズに応じて設定してください。
(例:ワークフローを実行した人に応じて、上司のチェックを挟むかどうか分岐。)

4. チャンネルへのアプリ追加を忘れずに

今回のアプリはチャンネルにメッセージを送信します。
そのため、対象チャンネルの「インテグレーション」から、作成した Slack アプリを招待する必要があります。

  • ワークフローのテストで「完了」になっているのにメッセージが届かない
  • 署名検証もエラーではなさそう

というときは、まず「チャンネルにアプリが入っているか」を疑うとよさそうです。

5. なぜ GAS を諦めて Lambda にしたのか

もともとは「GAS で全部やれたら手軽だな」と思い、先に GAS で実装していました。
しかし、次の理由から本番利用は見送りました。

  • GAS は関数の実行に 1 分以上のラグ が出ることがある
  • Slack 側は非公式ながら
    • リクエストに対して 3〜5 秒以内 にレスポンスがないとエラー扱いになる

この組み合わせだと、ワークフローに組み込む用途では致命的でした。
ただし、Lamdbaの場合でもコールドスタートによって遅延が発生し、ぎりぎり間に合わないことがあります。
Lambdaのメモリ増設によって対応できますが、呼び出し頻度が高い場合はSQSの併用も検討してください。

まとめ

この記事では、Slack ワークフローのカスタムステップを使って

  • ボタン付き/ボタンなしメッセージを送り分ける
  • ボタンの結果に応じてワークフローを継続 or 終了する

という処理を、AWS Lambda で実装するまでの流れをまとめました。

カスタムステップは、上位プランの「条件分岐」が使えない環境でも、ちょっとした承認フローや分岐ロジックを実現できる強力な手段だと感じました。

この記事のコードや構成をベースに、自分のワークフローに合わせてカスタマイズしてもらえればうれしいです。


16
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?