Slack ワークフローのカスタムステップを使って、
- 入力に応じて「ボタン付きメッセージ」または「通常メッセージ」を送信し
- ボタンの結果に応じてワークフローを継続 or 終了する
という処理を実装したときのメモです。
公式ドキュメントやブログ記事は増えてきましたが、「実際のコード全体」と「ハマりどころ」がまとまっている記事が少なかったので、自分が詰まったポイントも含めて整理しました。
- Slack 側:カスタムステップを含むワークフロー
- 実装側:AWS Lambda(+ Lambda Function URL)
- 言語:JavaScript(Node.js)
「とりあえず動くところまで持っていきたい」「GAS とどっちを選ぶか悩んでいる」方の参考になればうれしいです。
ゴールと要件
ゴール
既存の Slack ワークフローに「カスタムステップ」を追加し、次のような動きを実現します。
- ワークフローから渡される input に応じて
- ボタン付きメッセージを送る
- または、ボタンなしメッセージを送ってそのまま次のステップへ進める
- ボタン付きメッセージの場合
- OK ボタン → ワークフローを次のステップへ進める
- NO ボタン → その場でワークフローを終了する
前提・制約
- 最近リリースされた「条件分岐(Branch)」機能は、弊社のプランでは利用不可
- プラン体系が新しくなり、一番高いプランでないと使えないとのこと
- そのため、カスタムステップ+ボタンを使って「なんちゃって承認フロー」を作る、という方針にしました。
全体構成のイメージ
この記事で扱う構成はざっくり次のとおりです。
-
Slack アプリ(カスタムステップ付き)を作成
-
Lambda(1 つのエンドポイント)で
- Challenge(url_verification)
- カスタムステップからの呼び出し(function_executed)
- インタラクション(ボタン押下)
を全部受ける
-
event.type / body の中身を見て、処理を振り分ける
-
最終的に
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 });
}
}
ここで sendMessage に function_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();
}
ポイントは次のあたりです。
-
valueにfunction_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. ワークフローの設定(カスタムステップ)
ワークフロー側では、ざっくり次のような構成にしています。
- トリガー(フォーム入力など)
- 入力結果を使いつつ、
WithButtonなどのフラグを決める - カスタムステップ(今回作った Slack アプリ)を実行
-
functions.completeSuccessの結果をもって、後続ステップへ
今回便宜的にWithButtonという変数を使用しましたが、フォームで収集できる要素なら何でも渡すことができるので、ニーズに応じて設定してください。
(例:ワークフローを実行した人に応じて、上司のチェックを挟むかどうか分岐。)
4. チャンネルへのアプリ追加を忘れずに
今回のアプリはチャンネルにメッセージを送信します。
そのため、対象チャンネルの「インテグレーション」から、作成した Slack アプリを招待する必要があります。
- ワークフローのテストで「完了」になっているのにメッセージが届かない
- 署名検証もエラーではなさそう
というときは、まず「チャンネルにアプリが入っているか」を疑うとよさそうです。
5. なぜ GAS を諦めて Lambda にしたのか
もともとは「GAS で全部やれたら手軽だな」と思い、先に GAS で実装していました。
しかし、次の理由から本番利用は見送りました。
- GAS は関数の実行に 1 分以上のラグ が出ることがある
- Slack 側は非公式ながら
- リクエストに対して 3〜5 秒以内 にレスポンスがないとエラー扱いになる
この組み合わせだと、ワークフローに組み込む用途では致命的でした。
ただし、Lamdbaの場合でもコールドスタートによって遅延が発生し、ぎりぎり間に合わないことがあります。
Lambdaのメモリ増設によって対応できますが、呼び出し頻度が高い場合はSQSの併用も検討してください。
まとめ
この記事では、Slack ワークフローのカスタムステップを使って
- ボタン付き/ボタンなしメッセージを送り分ける
- ボタンの結果に応じてワークフローを継続 or 終了する
という処理を、AWS Lambda で実装するまでの流れをまとめました。
カスタムステップは、上位プランの「条件分岐」が使えない環境でも、ちょっとした承認フローや分岐ロジックを実現できる強力な手段だと感じました。
この記事のコードや構成をベースに、自分のワークフローに合わせてカスタマイズしてもらえればうれしいです。