📝

Slack アプリでのモーダルの使い方完全ガイド

2024/06/04に公開

こんにちは、Slack の公式 SDK 開発と日本の Developer Relations を担当している瀬良 (@seratch) と申します 👋

この記事では、Slack アプリでエンドユーザーからの情報送信を受け付けたり、インタラクティブなインタフェースを提供するために利用できる「モーダル」について知っておくべきことを可能な限り全て網羅していきます。

この記事で網羅しているトピック

もし、以下のようなことを疑問に思って Google 検索をしてこの記事にたどり着いたようでしたら、この(長い)記事のどこかにきっと必要な情報があるはずです。該当の箇所を読んでみてください。

  • モーダルを使うための基本的な手順
  • モーダルの API に渡すパラメータの詳細
  • モーダルからのデータ送信の留意点
  • モーダルからのデータ送信に対する応答方法
  • モーダルからのデータ送信以外のインタラクションへの応答方法
  • モーダルの操作の後にチャンネルにメッセージ送信

公式ドキュメントのご紹介

まず、英語のみとなりますが、公式ドキュメントの URL をご紹介しておきます。

この記事では Python 版の Bolt を使って説明をしていきますが、JavaScript(Node.js)、Python、Java での Bolt ドキュメントの該当箇所もご紹介しておきます。これらは日本語化されています。特に Java 版のドキュメントは、この記事が詳しく説明していくことをかなり網羅しています。

必要に応じて上記のドキュメントも参考にしていただければと思います。

この記事のサンプルを手元で動かす準備

この記事で説明するサンプルアプリをセットアップして、手元で動かせるようにしてみましょう。とりあえず、記事を眺めたいという方は、とりあえず今は読み飛ばして、動かしてみようとなったときに参考にしてもらっても問題ありません。

Slack アプリ設定

それでは、まずはよくあるユースケースでどのようにモーダルを利用できるかをご紹介します。

なお、このアプリで使うサンプルアプリの設定は以下の App Manifest で簡単に作ることができます。https://api.slack.com/apps?new_app=1 からアプリをつくるときに「From an app manifest」を選んで以下の YAML の設定を貼り付けてください。

display_information:
  name: simple-modal-app
features:
  bot_user:
    display_name: simple-modal-app
  shortcuts:
    - name: modal-shortcut
      type: global
      callback_id: modal-shortcut
      description: Global shortcut for opening a modal
  slash_commands:
    - command: /modal-command
      description: Slash command for opening a modal
oauth_config:
  scopes:
    bot:
      - commands
      - chat:write
      - app_mentions:read
settings:
  event_subscriptions:
    bot_events:
      - app_mention
  interactivity:
    is_enabled: true
  socket_mode_enabled: true

なお、いろいろな機能を有効にしているのはこの記事が網羅的に解説をするためです。ご自身のアプリでスラッシュコマンドやイベント受信を使わない場合は、設定する必要はありません。bot user が存在しているアプリであれば、モーダルの処理に最低限必要な設定は以下の settings.interactivity.is_enabled: true だけです。

settings:
  interactivity:
    is_enabled: true

また、上記の設定では settings.socket_mode_enabled: true となっていますが、これは、手元で簡単に動かすためにソケットモードを有効にしているものです。

アプリの設定ができたら、二つのことを実行してください。

まず、画面の左側の Settings > Basic Settings > App-Level Tokens のところで connections:write のスコープを持ったトークンをつくって SLACK_APP_TOKEN として環境変数に設定してください。

そして、画面の左側の Settings > Install App からアプリを Slack ワークスペースにインストールして、発行された xoxb- から始まる Bot User OAuth TokenSLACK_BOT_TOKEN として環境変数に設定してください。これでアプリ設定のセットアップとトークン発行の手順は完了です。

Python アプリケーションの雛形

次に Python 3.6 以上の環境でシンプルな Python の仮想環境をセットアップします。Poetry を使っている場合は poetry init -n && poetry shell && poetry add slack-bolt で十分でしょう。

python3 --version  # 3.6 以上である必要がある
python3 -m venv .vnenv
source .venv/bin/activate
pip install -U pip
echo 'slack-bolt' > requirements.txt
pip install -r requirements.txt

ソースコードにはこれからモーダルに関する部分を足していきますが、以下の雛形をとりあえず app.py として保存します。

import os
import logging
from slack_bolt import App, Ack, Say, BoltContext, Respond
from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk import WebClient

# デバッグレベルのログを有効化
logging.basicConfig(level=logging.DEBUG)
# これから、この app に処理を設定していきます
app = App(token=os.environ.get("SLACK_BOT_TOKEN"))

# これから説明するサンプルコードはここに追加していってください

if __name__ == "__main__":
    # ソケットモードのコネクションを確立
    SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()

上の手順で SLACK_APP_TOKENSLACK_BOT_TOKEN という環境変数を設定したという前提で Bolt for Python のアプリケーションを起動してみましょう。

export SLACK_APP_TOKEN=xapp-1-...
export SLACK_BOT_TOKEN=xoxb-...
python app.py

起動して以下のように hello のメッセージの受信までできていれば問題ないでしょう。

INFO:slack_bolt.App:A new session has been established (session id: xxx)
INFO:slack_bolt.App:⚡️ Bolt app is running!
INFO:slack_bolt.App:Starting to receive messages from a new connection (session id: xxx)
DEBUG:slack_bolt.App:on_message invoked: (message: {"type":"hello","num_connections":1,"debug_info":{"host":"applink-xxx","build_number":3,"approximate_connection_time":18060},"connection_info":{"app_id":"A11111"}})
DEBUG:slack_bolt.App:A new message enqueued (current queue size: 1)
DEBUG:slack_bolt.App:A message dequeued (current queue size: 0)
DEBUG:slack_bolt.App:Message processing started (type: hello, envelope_id: None)
DEBUG:slack_bolt.App:Message processing completed (type: hello, envelope_id: None)

モーダルを使うための基本的な手順

モーダルを使ったアプリを早速動かしてみましょう。手順は主に以下の 3 つです。

  1. アプリ設定画面の Interactivity & Shortcuts 画面で機能を有効にする
  2. ユーザー行動を起点に views.open API でモーダルを開始する
  3. モーダルからのデータ送信は @app.view リスナーで受け付ける

上の手順のうち 1. のアプリの設定は上の YAML を使った設定で既に完了しています。正常に設定が完了していればアプリの設定画面は以下のように機能が ON になっているはずです。

それでは、残りの 2. と 3. のパートである、モーダルを開いてデータを受け付けることを説明していきます。

データ送信を受け取るモーダル

後ほどより詳しいところは説明していきますが、まずは今回のアプリに設定されたスラッシュコマンド /modal-command でアプリを開いてテキスト入力を一件受け付けるコード例を見ていきます。

ユーザー行動を起点に views.open API でモーダルを開始する

まずモーダルを開く処理を実装する上で知っておくべきことが 2 つあります。

  • 新しいモーダルでのやりとりを開始するには views.open API を呼び出す
  • この API 呼び出しには、ユーザー行動(Events API 以外)でのみ発行される trigger_id を渡す必要がある

上記の二つ目の文中の「ユーザー行動(Events API 以外)」とは、この記事執筆時点では以下が該当します。

  • スラッシュコマンドの実行
  • グローバルショートカットの実行
  • メッセージショートカットの実行
  • メッセージまたはホームタブでのボタンクリック
  • メッセージまたはホームタブでのセレクトメニュー(プルダウン)でのアイテム選択

エンドユーザーがこれらの行動を行ったときに送信されるペイロードに trigger_id が含まれます。

「Events API (Event Subcriptions) では trigger_id は発行されない」ということに注意してください。例えば app_mention イベント(アプリの bot user をメンションしてメッセージを送信したイベント)のようなユーザーの行動に起因して送信されるイベントであっても Events API の場合は trigger_id は発行されません。Events API からモーダルを開く実装例は後ほど紹介します。

とりあえず、ここではスラッシュコマンドを使ってモーダルを開くところまでを説明します。モーダルが開く様子は以下のようになります。

以下がこのスラッシュコマンド実行のハンドリングをするリスナー関数のコード例です。一行ずつ詳細なコメントをつけておきましたので、リンクされている URL も参考にしてみてください。

@app.command("/modal-command")
def handle_some_command(ack: Ack, body: dict, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # views.open という API を呼び出すことでモーダルを開きます
    client.views_open(
        # 上記で説明した trigger_id で、これは必須項目です
        # この値は、一度のみ 3 秒以内に使うという制約があることに注意してください
        trigger_id=body["trigger_id"],
        # モーダルの内容を view オブジェクトで指定します
        view={
            # このタイプは常に "modal"
            "type": "modal",
            # このモーダルに自分で付けられる ID で、次に説明する @app.view リスナーはこの文字列を指定します
            "callback_id": "modal-id",
            # これは省略できないため、必ず適切なテキストを指定してください
            "title": {"type": "plain_text", "text": "テストモーダル"},
            # input ブロックを含まないモーダルの場合は view から削除することをおすすめします
            # このコード例のように input ブロックがあるときは省略できません
            "submit": {"type": "plain_text", "text": "送信"},
            # 閉じるボタンのラベルを調整することができます(必須ではありません)
            "close": {"type": "plain_text", "text": "閉じる"},
            # Block Kit の仕様に準拠したブロックを配列で指定
            # 見た目の調整は https://app.slack.com/block-kit-builder を使うと便利です
            "blocks": [
                {
                    # モーダルの通常の使い方では input ブロックを使います
                    # ブロックの一覧はこちら: https://api.slack.com/reference/block-kit/blocks
                    "type": "input",
                    # block_id / action_id を指定しない場合 Slack がランダムに指定します
                    # この例のように明に指定することで、@app.view リスナー側での入力内容の取得で
                    # ブロックの順序に依存しないようにすることをおすすめします
                    "block_id": "question-block",
                    # ブロックエレメントの一覧は https://api.slack.com/reference/block-kit/block-elements
                    # Works with block types で Input がないものは input ブロックに含めることはできません
                    "element": {"type": "plain_text_input", "action_id": "input-element"},
                    # これはモーダル上での見た目を調整するものです
                    # 同様に placeholder を指定することも可能です 
                    "label": {"type": "plain_text", "text": "質問"},
                }
            ],
        },
    )

コメントでの説明に加えていくつか補足です。

最初の行でまず ack() (この処理は内部的には WebSocket で応答メッセージを送信しています)を呼び出していますが、これを 3 秒以内に行わない場合、エンドユーザーにコマンド実行がタイムアウトとなった旨のエラーが表示されます。

そして、次の行で Web API のクライアントを使って views.open API を呼び出すことで、このスラッシュコマンドを実行したユーザーに対してモーダルを開いています。trigger_id がこのコマンド実行をしたユーザーに紐づいているため、ユーザー ID をパラメーターとして渡す必要はありません。

ちなみに、この trigger_id も 3 秒以内に使用しなければならないという制約があるため、モーダルの view を組み立てるために何らかの外部の API 等を呼び出す場合、その処理のパフォーマンスが安定的に高速であるかを確認するようにしてください。

あと、コード内のコメントでも書きましたが、view の見た目を自分で作るときには Block Kit Builder を使うと便利です。まだ使ったことがない方はこれを機にぜひ試してみてください。

https://qiita.com/seratch/items/628751be65de9eb23a80

モーダルの view オブジェクトの設定方法についてのさらなる詳しい情報は、公式ドキュメント(英語)も参考にしてみてください。

モーダルからのデータ送信は @app.view リスナーで受け付ける

モーダルを開くことができたので、これを使って何か情報を入力して送信してみましょう。

この処理が動作する様子は以下の通りです。

質問の項目が最低 5 文字という桁数チェックに引っかかったときは該当のブロックが赤く表示されています。この場合、ユーザーは入力内容がそのまま維持された状態で内容を変更して再送信することができます。

このデータ送信を受け付けるコード例は、以下の通りです。ポイントは view.state.values から input ブロックの入力内容を受け取れること、そして ack() で応答するということです。

# view.callback_id にマッチングする(正規表現も可能)
@app.view("modal-id")
def handle_view_events(ack: Ack, view: dict, logger: logging.Logger):
    # 送信された input ブロックの情報はこの階層以下に入っています
    inputs = view["state"]["values"]
    # 最後の "value" でアクセスしているところはブロックエレメントのタイプによっては異なります
    # パターンによってどのように異なるかは後ほど詳細を説明します
    question = inputs.get("question-block", {}).get("input-element", {}).get("value")
    # 入力チェック
    if len(question) < 5:
        # エラーメッセージをモーダルに表示
        # (このエラーバインディングは input ブロックにしかできないことに注意)
        ack(response_action="errors", errors={"question-block": "質問は 5 文字以上で入力してください"})
        return

    # 正常パターン、実際のアプリではこのタイミングでデータを保存したりする
    logger.info(f"Received question: {question}")

    # 空の応答はこのモーダルを閉じる(ここまで 3 秒以内である必要あり)
    ack()

ここでの例では、成功のパターンで単にそのモーダルを閉じていますが、さらに 2 ページ目を表示したり、上にモーダルを追加で重ねたり、メッセージを送信したりすることもできます。この辺は後ほど「 @app.view リスナーでの response_action を使った応答」や「モーダル操作の後にチャンネル・DM メッセージ送信」のところで詳しく説明します。

ということで、以上が基本的なモーダルの実装とその動作でした。ここからはさらに細かい点について解説していきます。

モーダルを開く方法の全パターン

上の例ではスラッシュコマンドでモーダルを開く例を紹介しましたが、このセクションではモーダルを開くことができる実装パターンを全て紹介していきます。

繰り返しとなりますが、この記事執筆時点でモーダルを開くための契機は以下のいずれかとなります。エンドユーザーがこれらの行動を行ったときに送信されるペイロードに trigger_id が含まれますので、それを使ってモーダルを開きます。

  • スラッシュコマンドの実行
  • グローバルショートカットの実行
  • メッセージショートカットの実行
  • メッセージまたはホームタブでのボタンクリック
  • メッセージまたはホームタブでのセレクトメニュー(プルダウン)でのアイテム選択

なお、ここからのコード例では、モーダルの見た目を構築するコードは以下のような共通関数にして、コード例をよりシンプルにすることにします。

def build_modal_view() -> dict:
    return {
        "type": "modal",
        "callback_id": "modal-id",
        "title": {"type": "plain_text", "text": "テストモーダル"},
        "submit": {"type": "plain_text", "text": "送信"},
        "close": {"type": "plain_text", "text": "閉じる"},
        "blocks": [
            {
                "type": "input",
                "block_id": "question-block",
                "element": {"type": "plain_text_input", "action_id": "input-element"},
                "label": {"type": "plain_text", "text": "質問"},
            }
        ],
    }

ショートカット(グローバル / メッセージ)

以下のようなショートカット起動からのモーダル表示です。

コード例は以下のようにスラッシュコマンドのときとほぼ同じです。trigger_id 以外の属性を使用する場合は body のデータ構造が異なることに注意してください。

# callback_id を指定します(正規表現も可能)
@app.shortcut("modal-shortcut")
def handle_shortcuts(ack: Ack, body: dict, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # views.open という API を呼び出すことでモーダルを開きます
    client.views_open(
        trigger_id=body["trigger_id"],
        view=build_modal_view(),
    )

割愛しますが、メッセージメニューから呼び出せるメッセージショートカットの場合も上記のコードがそのまま動作します。

ボタンのクリック・セレクトメニューでの選択

ユーザーがメッセージやホームタブに置かれたボタンを押したときに、そのユーザーに対してモーダルを表示することができます。

上記の例は、以下のようなメッセージが送信されている前提で、

client.chat_postMessage(
    channel="#demo",
    blocks=[
        {
            "type": "section",
            "block_id": "button-block",
            "text": {
                "type": "mrkdwn",
                "text": "モーダルをテストしてみましょう :wave:",
            },
            "accessory": {
                "type": "button",
                "text": {"type": "plain_text", "text": "モーダルを開く"},
                "value": "clicked",
                # この action_id を @app.action リスナーで指定します
                "action_id": "open-modal-button",
            },
        }
    ],
    text="通知や検索インデックスの登録に使われるテキスト",
)

ボタンがクリックされると open-modal-button という action_id でクリックイベントを受信できるので、それへの応答で views.open API を呼び出して、モーダルを開いています。

# action_id にマッチング(block_id ではないので注意)
@app.action("open-modal-button")
def handle_open_modal_button_clicks(ack: Ack, body: dict, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # views.open という API を呼び出すことでモーダルを開きます
    client.views_open(
        trigger_id=body["trigger_id"],
        view=build_modal_view(),
    )

割愛しますが、セレクトメニュー(プルダウンメニュー)で選択肢が選ばれたときにも同様に @app.action でハンドリングするイベントが送信されますので、それをモーダルを開くきっかけとして使うことができます。

また、このボタンやセレクトメニューは、ホームタブ上にも配置することができます。ホームタブは app_home_opened イベント(このイベントを使って設定することが一般的ですが任意のタイミングで更新も可能です)で views.publish API を使って設定できるユーザーごとの独自ビューです。ホームタブについての一般的な情報は以下の記事も参考にしてみてください。

https://qiita.com/tomomi_slack/items/fae66bcc2ec3ccf25db6

https://qiita.com/seratch/items/124a5cddbc8f06996af7

Events API からメッセージを投稿後、ボタンからモーダルを開く

上のセクションで「Events API から直接モーダルを開くことができない」と説明しました。では、チャンネル内のメッセージを使ったやりとりでモーダルを開きたいという場合はどうするのがよいでしょうか?

ユーザー体験としては 2 ステップにはなってしまいますが、一旦メッセージを投稿してそこにボタンを置くというのが一般的なやり方です。そのユーザーがボタンをクリックしたら trigger_id が発行されますので、それを使ってモーダルを開くことができます。

@app.event("app_mention")
def handle_app_mention_events(event: dict, say: Say):
    # ack() は @app.event の場合、省略可能
    # ボタンつきのメッセージを投稿、ボタンが押されたらモーダルを開く
    say(blocks=[
        {
            "type": "section",
            "block_id": "button-block",
            "text": {
                "type": "mrkdwn",
                "text": "Events API から直接モーダルを開くことはできません。ボタンをクリックしてもらう必要があります。",
            },
            "accessory": {
                "type": "button",
                "text": {"type": "plain_text", "text": "モーダルを開く"},
                "value": "clicked",
                # この action_id を @app.action リスナーで指定します
                "action_id": "open-modal-button",
            },
        }
    ])

# action_id にマッチング(block_id ではないので注意)
@app.action("open-modal-button")
def handle_open_modal_button_clicks(ack: Ack, body: dict, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # views.open という API を呼び出すことでモーダルを開きます
    client.views_open(
        trigger_id=body["trigger_id"],
        view=build_modal_view(),
    )

「このボタンを対象のユーザーだけがクリックできるようにしたい」という場合は、返信で投稿していたメッセージをチャンネルではなく DM で送るか、

@app.event("app_mention")
def handle_app_mention_events(event: dict, client: WebClient, context: BoltContext):
    # ボタンつきの DM メッセージを投稿、ボタンが押されたらモーダルを開く
    client.chat_postMessage(
        channel=context.user_id,  # ユーザーとの DM を開始してメッセージ投稿
        blocks=[]  # 同じなので省略
    )

エフェメラルメッセージ(「あなただけに表示されています」という表示になっているものです)を使ってもよいでしょう。

@app.event("app_mention")
def handle_app_mention_events(event: dict, client: WebClient, context: BoltContext):
    # ボタンつきのエフェメラルメッセージを投稿、ボタンが押されたらモーダルを開く
    client.chat_postEphemeral(
        user=context.user_id,
        channel=context.channel_id,
        blocks=[]  # 同じなので省略
    )

モーダルを表す三種類の ID

このパートでは、モーダルを扱う際に出てくる ID について整理しておきましょう。モーダル全体を指し示す ID として、以下の三種類が存在します。

名称 説明
view_id views.openviews.push API で新しいモーダルビューを作ったときに自動的に発行される自動生成の ID。views.update API で更新する際に渡す。同じ見た目のモーダルであっても、開かれる毎にユニークな ID が発行される。
external_id views.openviews.push API で新しいモーダルビューを作ったときに指定できる開発者が決める ID 文字列。views.update API で更新する際に渡す。同じ見た目のモーダルであっても、開かれる毎にユニークな ID を指定する必要がある。
callback_id モーダルの JSON データの中に含める項目で @app.view リスナーでデータ送信を受け取るときにモーダルを特定するために使用する ID。別のユーザーからのデータ送信であっても同じ callback_id をハンドリングするリスナーを使用できる。

view_id, external_id

view_idexternal_id という最初の二つは、ほとんどの場合、views.update API を呼び出す場合に使用します。例として、以下の様な標準ボタン以外のボタンからモーダルが更新されるアプリを考えてみましょう。

モーダルを開くところの実装は以下になります。external_id を使いたい場合は views.open API の view パラメーターのデータに external_id を指定してください。なお、その値は、このアプリにとって同一の Slack ワークスペース内でユニークな文字列である必要があります。

@app.shortcut("modal-shortcut")
def handle_shortcuts(ack: Ack, body: dict, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # views.open という API を呼び出すことでモーダルを開きます
    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal",
            "callback_id": "modal-id",
            # external_id を指定する場合はこの階層に追加する
            # "external_id": "unique-string",
            "title": {"type": "plain_text", "text": "テストモーダル"},
            "submit": {"type": "plain_text", "text": "送信"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [
                {
                    "type": "actions",
                    "block_id": "refresh-block",
                    "elements": [
                        {
                            "type": "button",
                            # この ID を @app.action リスナーで指定します
                            "action_id": "update-modal",
                            "text": {"type": "plain_text", "text": "このモーダルを更新する"},
                            "value": "clicked",
                            "style": "primary",
                        }
                    ],
                },
            ],
        },
    )

ボタンがクリックされたときに呼び出される処理はこちらです。view_idexternal_idbody パラメーターから取り出して渡します。

@app.action("update-modal")
def handle_update_modal_clicks(ack: Ack, body: dict, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # view.id を渡して、今あるモーダルを更新します
    client.views_update(
        # view.id は必ず存在します
        # view.external_id を使う場合は view_id の指定は不要です
        view_id=body.get("view").get("id"),
        # hash は race condition による不正更新を防ぐために指定できます
        hash=body.get("view").get("hash"),
        view={
            "type": "modal",
            "callback_id": "modal-id",
            "title": {"type": "plain_text", "text": "テストモーダル"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [
                {
                    "type": "section",
                    "text": {"type": "plain_text", "text": "このモーダルは更新されました!"},
                }
            ],
        },
    )

callback_id

一方、標準の「送信」ボタンを使う場合、モーダルの操作という意味では callback_id しか使いません。 上の例と少し似た、標準の送信ボタンを使った例で説明します。

モーダルを開く部分はこのような実装になります。

@app.shortcut("modal-shortcut")
def handle_shortcuts(ack: Ack, body: dict, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # views.open という API を呼び出すことでモーダルを開きます
    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal",
            # この ID を使って @app.view リスナーで処理をします
            "callback_id": "modal-id",
            "title": {"type": "plain_text", "text": "テストモーダル"},
            "submit": {"type": "plain_text", "text": "このモーダルを更新する"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [
                {
                    "type": "section",
                    "text": {"type": "plain_text", "text": "このまま送信してください。"},
                }
            ],
        },
    )

このモーダルの「このモーダルを更新する」ボタンが押されたときの処理はどのようになるでしょうか?

@app.view リスナーの中では ack() メソッドに response_action というコードとともに、モーダルのビューの内容やエラーメッセージを必要に応じて渡します。そして、この処理の中で view_idexternal_id を使うことはありません。

# view.callback_id にマッチングする(正規表現も可能)
@app.view("modal-id")
def handle_view_events(ack: Ack, view: dict):
    # response_action は update / push / errors / clear のいずれかです
    ack(
        response_action="update",
        view={
            "type": "modal",
            "callback_id": "modal-id",
            "title": {"type": "plain_text", "text": "テストモーダル"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [
                {
                    "type": "section",
                    "text": {"type": "plain_text", "text": "このモーダルは更新されました!"},
                }
            ],
        },
    )

他のパターンについては次のセクションで詳しく紹介します。

モーダルインタラクションへの二種類の応答方法

このセクションでは、モーダルからのデータ送信・インタラクションをどうハンドリングするかについて全てのパターンを説明していきます。大分類としては以下の二つがあります。

  • @app.view リスナーでの response_action を使った応答
  • それ以外のタイミングでの views.update/push API 利用

@app.view リスナーでの response_action を使った応答

まずは @app.view リスナーでの応答です。これはモーダルの最下部にある標準の「送信」ボタンが押されたときのパターンです。

データ送信で受け取ったデータの扱い方

@app.view リスナーで受け取る入力値の扱い方については、以下の点を押さえておくとよいでしょう。

  • @app.view リスナーでは state.values.{block_id}.{action_id} の階層で値を受け取ることができる
  • input ブロックの入力はデフォルトで全て必須になっているのを変更するには、ブロックレベルで optional: true を指定する

上記を網羅しているシンプルなコード例を挙げておきます。

@app.shortcut("modal-shortcut")
def handle_shortcuts(ack: Ack, body: dict, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # views.open という API を呼び出すことでモーダルを開きます
    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal",
            "callback_id": "modal-id",
            "title": {"type": "plain_text", "text": "テストモーダル"},
            "submit": {"type": "plain_text", "text": "送信"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [
                {
                    "type": "section",
                    # block_id はモーダル内でユニークでなければならない
                    "block_id": "user-section",
                    "text": {
                        "type": "mrkdwn",
                        "text": "これは section ブロックです",
                    },
                    "accessory": {
                        "type": "users_select",
                        # action_id がこのモーダル内でユニークである必要はない
                        "action_id": "section-block-users-select",
                    },
                },
                {
                    "type": "input",
                    "block_id": "text-input",
                    "element": {"type": "plain_text_input", "action_id": "action-id"},
                    "label": {"type": "plain_text", "text": "テキスト"},
                },
                {
                    "type": "input",
                    "block_id": "date-input",
                    "element": {"type": "datepicker", "action_id": "action-id"},
                    "label": {"type": "plain_text", "text": "日付"},
                },
            ],
        },
    )

# sections ブロックの選択がされたときに呼び出されます
@app.action("section-block-users-select")
def handle_some_action(ack, body, logger):
    ack()
    logger.info(body)

# 「送信」ボタンが押されたときに呼び出されます
@app.view("modal-id")
# @app.view({"type": "view_closed", "callback_id": "modal-id"})
def handle_view_submission(ack: Ack, view: dict, logger: logging.Logger):
    ack()
    # state.values.{block_id}.{action_id}
    logger.info(view["state"]["values"])

モーダルの実際の見た目はこのようになります。

そして、送信ボタンを押したときに @app.view リスナーで受け取る view.state.values の値は以下の通りです。

{
  "user-section": {
    "section-block-users-select": {
      "type": "users_select",
      "selected_user": "UJ521JPJP"
    }
  },
  "text-input": {
    "action-id": {
      "type": "plain_text_input",
      "value": "これはテキストです"
    }
  },
  "date-input": {
    "action-id": {
      "type": "datepicker",
      "selected_date": "2022-04-13"
    }
  }
}

{block_id}.{action_id} の下の属性名はブロックエレメントの種別によって異なります。以下に執筆時点の一覧をまとめました。最新情報はこちらも参考にしてください。

属性のキー名 ブロックエレメント
value string plain_text_input
selected_date string (YYYY-MM-DD 形式) date_picker
selected_time string (MM:SS 形式) time_picker
selected_conversation string (channel ID) conversations_select
selected_conversations string[] multi_conversations_select
selected_channel string (channel ID) channels_select
selected_channels string[] multi_channels_select
selected_user string (user ID) users_select
selected_users string[] multi_users_select
selected_option object static_select / external_select / radio_buttons
selected_options object[] multi_static_select / multi_external_select / checkboxes

selected_option(s) のデータ構造は以下のようになります。

{
  "text": {
    "type": "plain_text",
    "text": "ラベル",
  },
  "value": "option に設定された value"
}

状態を簡単に引き渡すためには private_metadata を使う

モーダルにセッション情報のような形で何か状態を保持したいというケースもあると思います。その場合は private_metadata という最大 3,000 文字までの文字列の値を裏で保持することができます。これはユーザーに見えるモーダルのビューには表示されないので、ID などの値を JSON 形式やカンマ・タブ区切りなどで保持することが一般的です。

この view.private_metadata@app.action@app.view リスナー内で view のデータが含まれるペイロードを受け取ったときに参照できます。

client.views_open(
    trigger_id=body["trigger_id"],
    view={
        "type": "modal",
        "callback_id": "modal-id",
        "title": {"type": "plain_text", "text": "テストモーダル"},
        "submit": {"type": "plain_text", "text": "送信"},
        "close": {"type": "plain_text", "text": "閉じる"},
        "private_metadata": "これはユーザーには見えない情報です",
        "blocks": [],
    },
)

応答の方法は ack() のみ

まず、上のセクションでも少し触れましたが、@app.view リスナーでモーダルを操作するときは、(後ほど説明する非同期での二回目の更新以外で) views.* API は使わないでください。 その代わりに、Bolt アプリケーションでは ack() メソッドに response_action というコードとともに、モーダルのビューの内容やエラーメッセージを必要に応じて渡します。

なお、Bolt を使って実装しない場合は、ソケットモードの場合は WebSocket の応答メッセージを送信、Request URL の場合は HTTP レスポンスボディに response_action とそれが必要とする情報を JSON 形式で渡す実装となります。

以下が response_action の一覧です。

response_action 説明・使い方
なし このモーダルだけを閉じる。ack() のように何も値を指定しない。
"errors" 送信されたモーダルに対してエラーメッセージをバインドする。errors という block_id をキーにしたエラーメッセージのマッピングを渡す。
"update" 送信されたモーダルを閉じずに、その内容を書き換える。複数ステップがある入力モーダルや画面遷移の表現に使える。view で更新後のモーダルの状態を渡す。
"push" 送信されたモーダルはそのまま置いておいて、その上の子供のモーダルを新しく重ねる。最大 3 枚までモーダルを重ねることができるview で新しく重ねるモーダルの状態を渡す。
"clear" "push" で複数モーダルを開いているとき、全てのモーダルを一気に閉じる。

response_action がなし、"errors""update" のパターンのコード例はすでにこの記事の中で紹介していますので該当の箇所を見てみてください。"push" は基本的には "update" とほぼ同じです。注意点としては callback_id を別のものにしておけば @app.view リスナーを分けることができますので、そうしたい場合は callback_id を使い分けるとよいでしょう。 "clear" に関しては ack(response_action="clear") を呼び出すだけです。

最後に最も注意すべき点は、ack() は 3 秒以内に呼び出す必要がある ということです。特に "errors" の場合は、バリデーションロジックがバックエンドを呼び出すなどの実装がありえるかと思いますが、その処理性能に注意してください。

また「どうしても ack() 呼び出しに必要な下準備を 3 秒以内に完了できない」という場合は、一旦 response_action: "update" で「処理中..」というような見た目に切り替えて、準備ができてから views.update API を使って再度更新する、という方法を取ることができます。

例として、私が以前実装した翻訳サービス連携の例を紹介します。以下のアニメーションを見てみてください。「Translating the text into ...」のビューがまさにそこです。

ここまで一貫して Python で実装例を紹介してきましたので、上の翻訳の例をより単純化したものを Python で簡単に実装してみました。二度のモーダル更新を行う例です。

モーダルの内容は、ここでは何でもよいのですが、このような形にします。

@app.shortcut("modal-shortcut")
def handle_shortcuts(ack: Ack, body: dict, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # views.open という API を呼び出すことでモーダルを開きます
    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal",
            "callback_id": "modal-id",
            "title": {"type": "plain_text", "text": "テストモーダル"},
            "submit": {"type": "plain_text", "text": "送信"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [
                {
                    "type": "input",
                    "block_id": "question-block",
                    "element": {
                        "type": "plain_text_input",
                        "action_id": "input-element",
                    },
                    "label": {"type": "plain_text", "text": "何らかのデータ登録"},
                },
            ],
        },
    )

「送信」ボタンが押されたときの処理は以下の通りです。ポイントは ack() でまず更新した上で、準備ができたら views.update API で再度更新をします。

# view.callback_id にマッチングする(正規表現も可能)
@app.view("modal-id")
def handle_view_events(ack: Ack, view: dict, client: WebClient):
    # まず「処理中...」である旨を伝えます
    ack(
        response_action="update",
        view={
            "type": "modal",
            "callback_id": "modal-id",
            "title": {"type": "plain_text", "text": "テストモーダル"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [
                {
                    "type": "section",
                    "text": {
                        "type": "plain_text",
                        "text": "処理中です... このモーダルを閉じずにしばらくお待ちください :bow:",
                    },
                }
            ],
        },
    )

    # 何か時間がかかる処理をシミュレートしているだけです
    import time
    time.sleep(3.5)  # 3.5 秒かかります

    # 結果を待った後 views.update API を非同期で呼び出して再度更新をかけます
    client.views_update(
        view_id=view.get("id"),
        view={
            "type": "modal",
            "callback_id": "modal-id",
            "title": {"type": "plain_text", "text": "テストモーダル"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [
                {
                    "type": "section",
                    "text": {"type": "plain_text", "text": "正常に完了しました!"},
                }
            ],
        },
        # ここでは ^ の ack() で hash がすでに更新されているので渡さない
        # hash=view.get("hash"),
    )

さて、上記の実装例、途中の状態で「処理中です... このモーダルを閉じずにしばらくお待ちください :bow:」とお願いをしています。待ちきれずにユーザーがモーダルを閉じてしまった場合、どうすればよいのでしょうか?その場合は、以下の方法をとることができます。

  • モーダルを開くときに notify_on_close: true という属性を設定しておく
  • このモーダルをユーザーが閉じたとき、type: "view_closed" のペイロードが送信されるのでそれをキャッチする
@app.shortcut("modal-shortcut")
def handle_shortcuts(ack: Ack, body: dict, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # views.open という API を呼び出すことでモーダルを開きます
    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal",
            "callback_id": "modal-id",
            "title": {"type": "plain_text", "text": "テストモーダル"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [],
            # この属性を指定することを忘れずに
            "notify_on_close": True,
        },
    )

@app.view_closed("modal-id")
# 以下の指定方法でも OK です
# @app.view({"type": "view_closed", "callback_id": "modal-id"})
def handle_view_closed(ack: Ack, view: dict, logger: logging.Logger):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()
    # 処理中だったバックエンド処理を中止したり、完了通知を DM に切り替えるために
    # この閉じられたという状態を view_id と紐づけて保存しておく
    logger.info(view)

それ以外のタイミングでの views.update/push API 利用

標準の送信ボタンではなく、blocks の中の section/actions ブロック内のボタンやセレクトメニューの操作によって発生したインタラクションをきっかけにモーダルに対して以下のことを行えます。

  • views.update API を呼び出して、インタラクションが発生したモーダルごと書き換える
  • views.push API を呼び出して、新しいモーダルを上に重ねる

なお、モーダルを閉じることはエンドユーザー自身にしかできないので、注意してください。

上の ack(response_action="...") のパターンとの大きな違いは

  • 3 秒以内に呼び出す必要がない
  • アプリ側からモーダルを閉じることはできない
  • input ブロックに対するエラーメッセージのバインディング(response_action="errors" 相当)はできない

となります。実装例は既に上で @app.action リスナーを紹介していますので、そちらを参考にされてください。

モーダル操作の後にチャンネル・DM メッセージ送信

この長い記事も最後のトピックとなりました。

モーダルでの情報入力が終わったときに「ありがとうございます」であったり、確認のために情報を DM で送ったり、起動したチャンネルに結果を通知したいというユースケースはよくあると思います。これを実装するためには二つの方法があります。

  • チャンネルからコマンド実行してモーダルを開いたときはそのチャンネル ID やユーザーの ID を private_metadata に保持しておく
  • ホームタブ内のボタン、検索バーからのグローバルショートカットなどはチャンネル以外から呼び出されるので、チャンネル ID が取得できなかった場合は response_url_enabled: true をモーダルに指定して、ユーザーに input ブロックで通知先のチャンネルを選んでもらうようにする

簡単な実装例を紹介します。

@app.shortcut("modal-shortcut")
def handle_shortcuts(ack: Ack, body: dict, context: BoltContext, client: WebClient):
    # 受信した旨を 3 秒以内に Slack サーバーに伝えます
    ack()

    # モーダルの基礎的なところを組み立てる
    modal_view = {
        "type": "modal",
        "callback_id": "modal-id",
        "title": {"type": "plain_text", "text": "テストモーダル"},
        "submit": {"type": "plain_text", "text": "送信"},
        "close": {"type": "plain_text", "text": "閉じる"},
        "private_metadata": "{}",
        "blocks": [],
    }
    # グローバルショートカットやホームタブのボタンなどだとチャンネルが紐づかないので
    # conversations_select のブロックを置いてそこでチャンネルを指定してもらいます
    if context.channel_id is None:
        modal_view["blocks"].append(
            {
                "type": "input",
                "block_id": "channel_to_notify",
                "element": {
                    "type": "conversations_select",
                    "action_id": "_",
                    # response_urls を発行するためには
                    # このオプションを設定しておく必要があります
                    "response_url_enabled": True,
                    # 現在のチャンネルを初期値に設定するためのオプション
                    "default_to_current_conversation": True,
                },
                "label": {
                    "type": "plain_text",
                    "text": "起動したチャンネル",
                },
            }
        )
    else:
        # private_metadata に文字列として JSON を渡します
        # スラッシュコマンドやメッセージショートカットは必ずチャンネルがあるのでこれだけで OK
        import json
        state = {"channel_id": context.channel_id}
        modal_view["private_metadata"] = json.dumps(state)

    # views.open という API を呼び出すことでモーダルを開きます
    client.views_open(
        trigger_id=body["trigger_id"],
        view=modal_view,
    )


@app.view("modal-id")
def handle_view_submission(ack: Ack, view: dict, say: Say):
    ack()
    # private_metadata か conversations_select ブロックからチャンネル ID を取得
    import json
    channel_to_notify = json.loads(view.get("private_metadata", "{}")).get("channel_id")
    if channel_to_notify is None:
        channel_to_notify = (
            view["state"]["values"]
            .get("channel_to_notify")
            .get("_")
            .get("selected_conversation")
        )
    # そのチャンネルに対して chat.postMessage でメッセージを送信します
    say(channel=channel_to_notify, text="Thanks!")

response_url_enabled を常に使っているモーダルであれば、チャンネル ID を取り出して say() を使うよりも respond() を使う方が楽でしょう。

@app.view("modal-id")
def handle_view_submission(ack: Ack, view: dict, respond: Respond):
    ack()
    # 指定されたチャンネルに対して response_url を使ってメッセージを送信します
    respond(text="Thanks!")

response_url_enabled については以下の記事でも解説したので、合わせて参考にしてみてください。

https://zenn.dev/slack/articles/256c916f71b343

まとめ

いかがだったでしょうか?

・・・長いですよね。リファレンスガイドとなるように書いたので長くなってしまいました。しかし、今まで受けた質問は出来る限り全て回答するように盛り込んだつもりです。Slack のモーダルを使って何か実装するときに読み返せるようぜひストックしておいてください!多くの方のお役に立てば幸いです。

それでは 👋

Slack

Discussion