SlackのBlock Kitボタンの二重クリックをRailsで防ぐ
SlackのBlock Kitを使うと、メッセージにボタンを付けて承認などの操作を受けられます。便利なのですが、このボタンは押しても消えません。 承認ボタンの先で外部サービスのAPIを呼ぶような処理だと、二重クリックがそのまま二重実行になってしまいます。 今回は、Slackワークフローの続きをRuby on Railsのアプリケーションで引き取り、Block Kitのボタンで承認を受ける実装を行なったので、その備忘録になります。 二重クリックへの対策は、下記の二段で行ないます。
- 画面側:
chat.updateで押されたメッセージを差し替える - サーバー側:GoodJobの
total_limitで同じJobを重ねて積まない
INFO
記事内のコードは、説明のために簡略化・変更しており、実際のコードとは異なります。クラス名やファイル名なども便宜上のものです。 エラー処理などは省略しているので、そのまま本番で使うものではなく、実装の参考としてご覧ください。
なぜワークフローの外で処理するのか
題材は、Slackワークフローで作った申請と承認の流れです。もともとはフォームでの申請から承認、完了の通知まで、ワークフローだけで完結していました。
ところが要件が変わり、流れの途中に添付ファイルを使った処理を挟む必要が出てきました。調べた範囲では、これはワークフローだけでは実現できませんでした。
- 標準の分岐:条件に使えるのは文字列や数値などで、ファイルは条件に使えない
- カスタムステップ:フォームでアップロードされたファイルを受け取れない(Slack公式SDKのissueでも未対応のまま)
ワークフローで完結できない以上、続きはシステム側で処理するしかありません。そこで、ワークフローの役割は「ファイル付きで投稿してBotにメンションする」までにしました。投稿とメンションは標準機能でできます。 メンションされるとSlackからRailsへイベントが届くので、添付ファイルはRails側からSlack APIで取りに行きます。
全体の流れは下記のようになります。
- ワークフロー:フォームを受け取り、チャンネルに投稿してBotにメンションする
- Rails:Slack APIで添付ファイルを取得し、処理する
- Rails:結果をボタン付きのメッセージでスレッドに返し、承認を受ける
- Rails:承認されたら後続の処理を行ない、完了を通知する
なお、実際には添付ファイルを使った処理がもう少し複雑で、それがワークフローの外で処理することにした理由です。記事ではBlock Kitと二重クリックの話に絞るため、添付ファイルの取得や処理のコードも省いています。
使用するgem
記事内のコードでは下記のgemを使用します。
- Slack Ruby Client:Slack APIの呼び出しと署名の検証
- GoodJob:ActiveJobのバックエンド(v4.22.0以上)
BotのトークンとSigning Secretは、credentialsにslack.api_tokenとslack.signing_secretとして保存しておきます。
Slack BotやトークンをRailsで扱う基本的な手順は、RailsアプリケーションでSlack通知を実装するにまとめています。
Slackアプリの設定
Slackアプリ側では下記を設定します。
- Event Subscriptions:
app_mentionを購読し、Request URLにRailsのエンドポイントを指定する - Interactivity & Shortcuts:Request URLにボタン押下を受けるエンドポイントを指定する
- Bot Token Scopes:
app_mentions:read、chat:write、files:read
Rails側のルーティングには、post 'slack/events', to: 'slack_events#create'のように、イベント用とボタン押下用の2つのエンドポイントを追加しておきます。
Botはチャンネルに招待しておきます。ワークフローの投稿メッセージの先頭で、このBotをメンションするように設定します。
app_mentionを受け取る
メンションされると、Slackから下記のようなリクエストが届きます。
{
"type": "event_callback",
"event": {
"type": "app_mention",
"channel": "C0XXXXXXX",
"ts": "1761530000.000100",
"text": "<@Bot> *申請者* <@U0XXX> …",
"files": [{
"name": "申請書.pdf",
"mimetype": "application/pdf"
}],
"workflow_id": "Wf0XXXXXXX"
}
}textにフォームの入力内容、filesに添付ファイルの名前、channelとtsに返信先のスレッドの情報が入っています。
ただし、メンションは人も手で打てます。ワークフローを通さなくても、同じapp_mentionが届いてしまいます。 そこで使えるのがworkflow_idです。ワークフローの投稿にだけ付いてくるので、これの有無で人のメンションと区別できます。
class SlackEventsController < ApplicationController
before_action :verify_slack_signature!
def create
return render json: { challenge: params[:challenge] } if params[:type] == 'url_verification'
enqueue_process_file if workflow_mention?
head :ok
end
private
def verify_slack_signature!
Slack::Events::Request.new(request, signing_secret: Rails.application.credentials.dig(:slack, :signing_secret)).verify!
rescue Slack::Events::Request::TimestampExpired, Slack::Events::Request::InvalidSignature
head :unauthorized
end
def workflow_mention?
event.present? && event[:type] == 'app_mention' && event[:workflow_id].present?
end
def enqueue_process_file
ProcessFileJob.perform_later(channel: event[:channel], thread_ts: event[:ts])
end
def event
params[:event]
end
endurl_verificationは、Request URLを登録するときにSlackから届く確認リクエストです。challengeの値をそのまま返します。 誰でもPOSTできるURLなので、署名の検証も入れています。Slackへは3秒以内に応答する必要があるので、重い処理はJobに切り出して、コントローラーはすぐに200を返します。
INFO
APIモードではない通常のRailsアプリでは、CSRFトークンの検証で弾かれるため、Slack用のコントローラーにskip_forgery_protectionを追加してください。
Block Kitのボタンで承認を受ける
ここからが本題です。処理の結果は、「承認」ボタン付きのメッセージでスレッドに返します。
メッセージを部品で組み立てる
Slackのメッセージは、Block Kitという部品の組み合わせで作れます。
def button_blocks(text, label, action_id)
[
{ type: 'section', text: { type: 'mrkdwn', text: } },
{ type: 'actions',
elements: [{ type: 'button', text: { type: 'plain_text', text: label }, action_id:, style: 'primary' }] }
]
endsection:本文。mrkdwnで太字やメンションも書けるactions:ボタンを並べる場所action_id:押されたボタンを見分けるためのID
この配列をchat.postMessageのblocksに渡すと、ボタン付きのメッセージとして表示されます。thread_tsを指定すると、スレッドへの返信になります。textはプッシュ通知など、blocksを表示できない場面で使われます。 ProcessFileJobの中で、処理の結果を下記のように投稿します。
text = '下のボタンから承認してください。'
Slack::Web::Client.new(token: Rails.application.credentials.dig(:slack, :api_token)).chat_postMessage(
channel:,
thread_ts:,
text:,
blocks: button_blocks(text, '承認', 'approve')
)ボタンの押下を受け取る
ボタンが押されると、Interactivityに設定したURLにリクエストが届きます。パラメータのpayloadはJSON文字列なのでパースして使います。署名の検証はSlackEventsControllerと同じなので省略しています。 今回はボタンが1つなのでそのままJobを積んでいますが、Request URLはアプリで1つなので、ボタンが複数あるときやほかの操作も受けるときはaction_idで見分けます。
class SlackInteractionsController < ApplicationController
before_action :verify_slack_signature!
def create
payload = JSON.parse(params[:payload])
ApproveJob.perform_later(
channel: payload['channel']['id'],
thread_ts: payload['container']['thread_ts']
)
head :ok
end
endこれで、申請から承認後の処理まで一通り動きます。
ボタンは押しても消えない
一通り動くようになりましたが、承認ボタンを2回押すとどうなるでしょうか。 Block Kitのボタンは押しても消えませんし、disabledのような指定もありません。そのままだとJobが2つ積まれ、同じ処理が2回走ってしまいます。外部サービスへの連携はやり直しの効かないことが多いので、ここはきちんと防いでおきたいです。
INFO
ボタンにはconfirmを指定して、押したときに確認ダイアログを出すこともできます。誤操作は減らせますが、確認の後に押された分までは止められないので、二重クリックの対策とは別物として考えます。
対策1:押されたメッセージをchat.updateで差し替える
まずは画面側の対策です。押されたメッセージをchat.updateで差し替えて、ボタンごと消します。Slack公式のサンプルでも同じようにchat.updateでボタンを消しています。 先ほどのSlackInteractionsControllerで、Jobを積む前に実行します。
class SlackInteractionsController < ApplicationController
# 省略
def create
payload = JSON.parse(params[:payload])
disable_button(payload)
ApproveJob.perform_later(
channel: payload['channel']['id'],
thread_ts: payload['container']['thread_ts']
)
head :ok
end
private
def disable_button(payload)
Slack::Web::Client.new(token: Rails.application.credentials.dig(:slack, :api_token)).chat_update(
channel: payload['channel']['id'],
ts: payload['container']['message_ts'],
text: '受け付けました。'
)
end
endただし、これだけでは防ぎきれません。ボタンが消えるのはchat.updateが反映された後なので、それより早い連打は押せてしまいます。消える前に届いた押下は、画面側では止められません。
対策2:GoodJobで同じスレッドのJobを重ねて積まない
そこでサーバー側でも止めます。GoodJobのConcurrency拡張を使うと、同時に存在できるJobの数を制限できます。
class ApproveJob < ApplicationJob
include GoodJob::ActiveJobExtensions::Concurrency
queue_as :default
good_job_concurrency_rule(
label: -> { "approve-#{arguments.sole[:thread_ts]}" },
apply_label: true,
total_limit: 1
)
def perform(channel:, thread_ts:)
# 後続の処理を行ない、完了をスレッドへ投稿する
end
endlabel:thread_tsを含めることで、スレッドごとのラベルになる。別の申請同士は影響しないapply_label: true:Jobにこのラベルを自動で付けるtotal_limit: 1:同じラベルの未完了Jobが待機中か実行中なら、2件目は積まずに捨てる
INFO
apply_labelはGoodJob v4.22.0で追加されたオプションです。それより古いバージョンでは、このコードのままでは使えません。 以前のgood_job_control_concurrency_withとkey:による指定は、v4.22.0で非推奨になりました。v5で削除される予定です。
enqueue_limitでは防げない
ここで注意したいのが、total_limitとenqueue_limitの違いです。名前からすると、積むのを制限するenqueue_limitのほうが合っていそうに見えますが、数える対象が違います。
total_limit:未完了のJobをすべて数える。実行中のJobも含むenqueue_limit:実行中のJobは数えない
enqueue_limit: 1だと、1件目が実行中のときは数が0のままなので、2件目が積まれます。つまり1件目の処理が走っている間に押された分は止められず、後からもう一度実行されてしまいます。そのため今回はtotal_limitを使います。
それでも完全には防げない
ただし、これでも完全ではありません。Jobの数を数えてから積むまでの間はロックが外れているので、ほぼ同時に押されると2件入る可能性があります。 絶対に防ぐなら、処理の前にスレッドをDBに記録し、一意制約で2件目を止める方法があります。
ただ、テーブルを1つ増やすとマイグレーションと管理が必要になり、処理に失敗したときの記録の後始末も考えなければいけません。すり抜けるのは、ほぼ同時に押されたときだけです。 今回は社内向けのシステムで利用者も限られるため、コストと見比べてここまでで十分と判断しました。不特定多数が使うサービスであれば、DBの一意制約まで入れておくのが良いと思います。
いかがだったでしょうか。Block Kitのボタンは手軽に使えますが、押しても消えないので、やり直しの効かない処理を載せるときは二重クリックを考えておく必要があります。 画面側のchat.updateとサーバー側のtotal_limitの二段で、ほとんどの二重クリックは防げます。どこまで防ぐかは、処理の重さとコストを見比べて決めるのが良さそうです。 また、Slackワークフローからファイルは渡せませんが(多分)、ファイル付きで投稿してBotにメンションすれば、続きを自分のアプリケーションで書けます。ご参考になれば幸いです。