Slack アプリでのモーダルの使い方完全ガイド
こんにちは、Slack の公式 SDK 開発と日本の Developer Relations を担当している瀬良 (@seratch) と申します 👋
この記事では、Slack アプリでエンドユーザーからの情報送信を受け付けたり、インタラクティブなインタフェースを提供するために利用できる「モーダル」について知っておくべきことを可能な限り全て網羅していきます。
この記事で網羅しているトピック
もし、以下のようなことを疑問に思って Google 検索をしてこの記事にたどり着いたようでしたら、この(長い)記事のどこかにきっと必要な情報があるはずです。該当の箇所を読んでみてください。
- モーダルを使うための基本的な手順
- モーダルの API に渡すパラメータの詳細
- モーダルからのデータ送信の留意点
- モーダルからのデータ送信に対する応答方法
- モーダルからのデータ送信以外のインタラクションへの応答方法
- モーダルの操作の後にチャンネルにメッセージ送信
公式ドキュメントのご紹介
まず、英語のみとなりますが、公式ドキュメントの URL をご紹介しておきます。
- Modals: focused spaces for user interaction
- Reference: Layout blocks
- Input block
- Reference: Block elements
この記事では 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 Token を SLACK_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_TOKEN
と SLACK_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 つです。
- アプリ設定画面の Interactivity & Shortcuts 画面で機能を有効にする
- ユーザー行動を起点に
views.open
API でモーダルを開始する - モーダルからのデータ送信は
@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 を使うと便利です。まだ使ったことがない方はこれを機にぜひ試してみてください。
モーダルの 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 を使って設定できるユーザーごとの独自ビューです。ホームタブについての一般的な情報は以下の記事も参考にしてみてください。
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.open や views.push API で新しいモーダルビューを作ったときに自動的に発行される自動生成の ID。views.update API で更新する際に渡す。同じ見た目のモーダルであっても、開かれる毎にユニークな ID が発行される。 |
external_id |
views.open や views.push API で新しいモーダルビューを作ったときに指定できる開発者が決める ID 文字列。views.update API で更新する際に渡す。同じ見た目のモーダルであっても、開かれる毎にユニークな ID を指定する必要がある。 |
callback_id |
モーダルの JSON データの中に含める項目で @app.view リスナーでデータ送信を受け取るときにモーダルを特定するために使用する ID。別のユーザーからのデータ送信であっても同じ callback_id をハンドリングするリスナーを使用できる。 |
view_id, external_id
view_id
と external_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_id
か external_id
を body
パラメーターから取り出して渡します。
@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_id
や external_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
については以下の記事でも解説したので、合わせて参考にしてみてください。
まとめ
いかがだったでしょうか?
・・・長いですよね。リファレンスガイドとなるように書いたので長くなってしまいました。しかし、今まで受けた質問は出来る限り全て回答するように盛り込んだつもりです。Slack のモーダルを使って何か実装するときに読み返せるようぜひストックしておいてください!多くの方のお役に立てば幸いです。
それでは 👋
Discussion