Skip to content
SlackのBlock Kitボタンの二重クリックをRailsで防ぐ記事のサムネイル

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で取りに行きます。

全体の流れは下記のようになります。

  1. ワークフロー:フォームを受け取り、チャンネルに投稿してBotにメンションする
  2. Rails:Slack APIで添付ファイルを取得し、処理する
  3. Rails:結果をボタン付きのメッセージでスレッドに返し、承認を受ける
  4. 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から下記のようなリクエストが届きます。

json
{
  "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です。ワークフローの投稿にだけ付いてくるので、これの有無で人のメンションと区別できます。

ruby
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
end

url_verificationは、Request URLを登録するときにSlackから届く確認リクエストです。challengeの値をそのまま返します。 誰でもPOSTできるURLなので、署名の検証も入れています。Slackへは3秒以内に応答する必要があるので、重い処理はJobに切り出して、コントローラーはすぐに200を返します。

INFO

APIモードではない通常のRailsアプリでは、CSRFトークンの検証で弾かれるため、Slack用のコントローラーにskip_forgery_protectionを追加してください。

Block Kitのボタンで承認を受ける ​

ここからが本題です。処理の結果は、「承認」ボタン付きのメッセージでスレッドに返します。

メッセージを部品で組み立てる ​

Slackのメッセージは、Block Kitという部品の組み合わせで作れます。

ruby
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' }] }
  ]
end
  • section:本文。mrkdwnで太字やメンションも書ける
  • actions:ボタンを並べる場所
  • action_id:押されたボタンを見分けるためのID

この配列をchat.postMessageのblocksに渡すと、ボタン付きのメッセージとして表示されます。thread_tsを指定すると、スレッドへの返信になります。textはプッシュ通知など、blocksを表示できない場面で使われます。 ProcessFileJobの中で、処理の結果を下記のように投稿します。

ruby
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で見分けます。

ruby
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を積む前に実行します。

ruby
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の数を制限できます。

ruby
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
end
  • label: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にメンションすれば、続きを自分のアプリケーションで書けます。ご参考になれば幸いです。