💬

Slack ペイロードに含まれる response_url を完全に理解する

2024/06/04に公開

response_url を理解しよう

この記事では、Slack アプリを作ったことがある人でもあまり馴染みがないかもしれない response_url という仕組みについて網羅的に説明してみたいと思います [1]

response_url は、スラッシュコマンドやショートカットのようなユーザーと Slack アプリの間で直接的なインタラクションが発生する機能のペイロードに含まれるものです。Incoming Webhookschat.postMessageで Slack に通知を送るだけの連携からもう一歩進んで、よりインタラクティブな Slack アプリを作るとき、この機能をうまく使うと、より良いユーザー体験を実現できるでしょう。

なお、この記事では日本語でできる限り丁寧に説明していきますが、こちらの英語のドキュメントにも多くの内容は書かれていますので、合わせて参考にしてみてください。
https://api.slack.com/interactivity/handling#message_responses

それでは、はじめましょう。読んでくれた方全員が「response_url 完全に理解した! [2]」と思ってもらえることが、この記事のゴールです。

response_url とは?

response_url は、一言で言えば「Incoming Webhooks と同じ要領でメッセージ送信にだけ使用できる URL」なのですが、できるだけ正確かつ端的に定義するならば・・

Slack から送信されるペイロードに含まれうる
特定のユーザーとチャンネルに紐づいていて
有効期限・利用回数上限がある
「あなただけに表示されています」なメッセージも送信可能な URL

です!

長いし・・わかりやすくは・・ないですよね。ここから、一つ一つ紐解いていきましょう。

Slack から送信されるペイロードに含まれうる

インタラクティブな Slack アプリをつくるとき、公開された HTTP エンドポイントを用意して、あらかじめその URL を Slack アプリの設定内の Request URL という項目に指定しておきます。そうすることで Slack の API サーバーが対象のイベントが発生したときにその情報を送信してきます。

ここでの「ペイロード」とは、その Request URL に送られてくる JSON データのことです。[3]

そして、この記事のテーマである response_url は、この JSON データの一部として送信されてくる URL です。例えば、以下のメッセージショートカットのペイロード例 [4] であれば https://hooks.slack.com/app/T111/123/xyzresponse_url となります。

{
  "type": "message_action",
  "action_ts": "1607081569.852971",
  "team": {
    "id": "T111",
  },
  "user": {
    "id": "W111",
  },
  "channel": {
    "id": "C1111",
  },
  "callback_id": "unique-id-for-message-shortcut",
  "trigger_id": "111.222.xyz",
  "response_url": "https://hooks.slack.com/app/T111/123/xyz",
  "message_ts": "1607081410.000900",
  "message": {
    "text": "<@W111> さん!このメッセージは say() を使って送信しました。",
    "ts": "1607081410.000900"
  }
}

では、どのような機能を使ったときに response_url を受け取れるのでしょうか?以下にインタラクティブな機能と response_url の有無を整理してみました。

機能名 response_url 有無 詳細
スラッシュコマンド(Slash Commands) ⭕ 常に有 スラッシュコマンドは必ずチャンネル内で実行されるので、常にチャンネルに紐づいた response_url がペイロードに含まれます
メッセージショートカット(Message Shortcuts) ⭕ 常に有 メッセージショートカットは必ずチャンネル内のメッセージを指定して起動されるので、そのチャンネルに紐づく response_url がペイロードに含まれます
ボタンやセレクトメニューのインタラクション (Block Kit) ⭕ メッセージは有

❌ モーダル・ホームタブは無
そのブロックがどこに配置されているかによります。
グローバルショートカット(Global Shortcuts) ❌ 常に無 グローバルショートカットはチャンネル以外でも実行されるので、特定のチャンネルに紐づく response_url はペイロードに含まれません
モーダルでのデータ送信(View Submissions) ❌ 基本は無

⭕ 特定条件下で response_urls として有
response_url_enabled: true な input block を持つ場合のみ response_urls が含まれます
イベント API(Events API) ❌ 常に無 ユーザーとの直接的なインタラクションではない Events API では response_url が含まれることがありません

端的には、Slack ワークスペースのエンドユーザーが、チャンネル(DM も含む)内で何かコマンド的な操作を行ったとき、そのペイロードに含まれます。

そのため、Block Kit の UI コンポーネントの場合、メッセージ内にあるボタンのクリックやセレクトメニューからアイテムを選択したときのみ含まれます。モーダルやホームタブの場合は発行されません。

一方、グローバルショートカットの場合は、検索ウィンドウから実行したときだけでなく、チャンネル内のメッセージ投稿エリアから起動した場合にも response_url は発行されません。グローバルショートカットでの対処法については後述します。

特定のユーザーとチャンネルに紐づいている

次に、ユーザー・チャンネル(DM も含む)との紐づけについてです。

既に上で説明した通りですが response_url は、チャンネルに紐づくユーザーアクションが発生したときのみ発行されるものです。上記の JSON ペイロード例で言えばチャンネルは C111 で、ユーザーは W111 ということになります。

response_url を使うと、その対象のチャンネルでのみメッセージを投稿できるようになりますが、それ以外の権限は一切付与されません[5]

そして response_url は、チャンネルだけでなく、そのアクションを発生させたユーザーにも紐づいています。このユーザーのコンテキスト情報を持っているため、Slack の画面上で「あなただけに表示されています」と表示されているメッセージも送信できます。

有効期限・利用回数上限がある

response_url には使用回数の上限と有効期限があります。

一つ一つの response_url は、それぞれ 30 分以内に 5 回まで使用することができます。この時間内であれば、非同期に利用することが可能です。しかし、データベースに保存しておいて翌日の日次バッチ処理で使用する、といったことはできません。

また 5 回まで利用できるのは、一つの URL で 5 件までメッセージが送信できるようにするためというよりは(それも可能ではありますが)、後述する更新処理を想定した利用許可となっています。

「あなただけに表示されています」なメッセージも送信可能な URL

「あなただけに表示されています」と表示されているメッセージは API ドキュメントでは エフェメラルメッセージ(ephemeral message) と呼ばれます。

「ephemeral」は日本語で言えば「一時的な」とか「つかの間の」といった意味である通り、一定時間が経過すれば、そのメッセージは表示されなくはなります。時限式で消えるというよりは Slack クライアントをリフレッシュしても消えるなど揮発性のある一時的なメッセージですね。

すでに軽く説明しましたが、response_url は、このエフェメラルメッセージを送信するために使えます。もちろん、エフェメラルメッセージだけでなく、普通のチャンネル上でのメッセージも投稿できます。これを切り替えるためには response_type という項目を設定します。通常のメッセージとして送信する場合は "in_channel" を指定します。後ほどのコード例でも紹介します。

扱い方をコード例で理解する

ということで、まずは response_url とは何なのかを説明しました。ここからは、コード例とともにその使い方ともう少し詳細な内容を解説していきます。

コード例には Slack の公式フレームワークである Bolt for Python を使います。Python に馴染みがない方には申し訳ないですが・・どれもシンプルなコード例なので、きっと理解しやすいはずです。

プロジェクトのセットアップ

Python 3.6 以上を使ってください。最近はあらかじめ入っている Python も python3 は 3.6 以上であることがほとんどだと思います。python3 --version で確認しておきましょう。

requirements.txt

依存ライブラリは slack_bolt だけで試せます。[6] GitHub のソースコードをダウンロードして使用したい場合は https://github.com/slackapi/bolt-python/releases にアクセスしてください。

slack_bolt>=1.4,<2

requirements.txt を配置したら以下を実行してみてください。Windows OS をお使いの方は、こちらのPython 公式ガイドを参考にしてみてください。

python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt

main.py

こちらのコードが雛形になりますので、そのままコピペして試せます。

import logging
from logging import Logger
from slack_bolt import App, Ack, BoltResponse, BoltContext, Respond, Say
from slack_sdk import WebClient
from typing import Callable, Dict, List

logging.basicConfig(level=logging.DEBUG)

# export SLACK_BOT_TOKEN=xoxb-***
# export SLACK_SIGNING_SECRET=***
app = App()

# ngrok を使っている場合 http://localhost:4040/inspect/http でも確認できますが
# リスナーのログと同時に見るためにペイロードを標準出力に表示するだけの middleware です
@app.use  # または @app.middleware でも可
def log_request(logger: Logger, body: dict, next: Callable[[], BoltResponse]):
    logger.info(body)
    next()

#
# ここに今から解説するコードをそのまま持ってくれば動作します
#

if __name__ == "__main__":
    app.start(3000)  # POST http://localhost:3000/slack/events

このコードだと変更する度に反映には再起動が必要ですが、自動反映させたい場合は Flask の開発モードを使いましょう(FastAPI などの他のフレームワークでも構いません)。 requirements.txtFlask>=1.1 を追加して app.start(3000) の代わりに以下のようにするとよいでしょう。

if __name__ == "__main__":
    # app.start(3000)
    from flask import Flask, request
    from slack_bolt.adapter.flask import SlackRequestHandler
    flask_app = Flask(__name__)
    handler = SlackRequestHandler(app)

    @flask_app.route("/slack/events", methods=["POST"])
    def slack_events():
        return handler.handle(request)

    flask_app.run(port=3000, debug=True)

これで基本的なアプリの雛形の準備ができました。次は Slack アプリとしての設定をしていきましょう。

Slack アプリの権限を設定して、インストール

このアプリを動作させるために必要な Slack アプリ設定をしていきます。

まず https://api.slack.com/apps?new_app=1 にアクセスして、そこから Slack アプリを作成します。

アプリ設定画面が表示されたら OAuth & Permissions では、Bot Token Scopes にアクセスして、以下のように権限を設定してください。

とりあえず、これだけ終わったら Settings > Install App にあるインストールボタンからワークスペースにインストールします。正常に完了すると Bot User OAuth Access Token が発行されます。インストール完了時の画面に表示されている xoxb- という形式で始まる長めの文字列です。

環境変数を設定してアプリを起動

アプリの設定画面にある

  • Settings > Install App にある Bot User OAuth Access Token
  • Settings > Basic Information にある Signing Secret

を、以下の環境変数で設定します(Windows OS の場合は setx などに読み換えてください)。

export SLACK_BOT_TOKEN=xoxb-***
export SLACK_SIGNING_SECRET=***

そして、いよいよアプリを起動してみましょう。エラーが特に表示されなければ大丈夫です。

source .venv/bin/activate
python3 main.py

別のターミナルで ngrok を立ち上げます。

ngrok http 3000

なお、このコマンドで立ち上げた場合、毎回サブドメインは変わります(一定時間が経過しても変わります)。ngrok の有料プランを契約すると、固定することもできます。

ngrok http 3000 --subdomain your-own-fixed-domain

これでここからのコード例を試す準備が整いました。最初のテーブルで説明した、各機能と response_url の関係性、扱い方の詳細を実際に試していきましょう。

スラッシュコマンド

スラッシュコマンドは、必ずチャンネル内で実行されるため、チャンネルに紐づいた response_url が常にペイロードに含まれます。

試すために、まずは Slack アプリの設定画面でスラッシュコマンドの設定をつくりましょう。Features > Slash Commands のセクションで /test-response-url という名前のものを、有効な Request URL とともに設定します。

準備ができたので、コード例をみていきます。

# スラッシュコマンドのリスナー
@app.command("/test-response-url")
def tell_response_url(ack: Ack, body: dict, respond: Respond):
    # 必ず存在します
    assert body.get("response_url") is not None

    # タイムアウトにならないように空の ack() を呼び出して 200 OK を即応答
    # 引数を渡せば、これを使って返信を投稿することもできます
    ack()

    # respond 関数にはペイロード内の response_url が自動的に設定されています
    respond("このメッセージ送信は response_url を使って送信されました!")

Bolt を使っている場合は respond() という関数に response_url があらかじめ紐づけられます。textblocks を渡せばメッセージの内容を指定できます。

もしエフェメラルではないメッセージを送信したいなら response_type="in_channel" も合わせて指定することができます。

    respond(
        response_type="in_channel",
        text="このメッセージ送信は response_url を使って送信されました!"
    )

このようにスラッシュコマンドでは、常に response_url を使うことができます。

なお、コメントにも書いてある通り、HTTP レスポンスで応答を返す ack() でも同様に返信することができます。

「・・・ん、この ack()respond() で返信するときの違いって何?」と思われた方、視点が鋭いですね。その違いについては、こちらの説明を参照してください。

メッセージショートカット

次はメッセージショートカットです。メッセージショートカットは、チャンネル内のメッセージを指定して起動されるので、そのチャンネルに紐づく response_url が必ずペイロードに含まれます。スラッシュコマンドと同様 Bolt では respond() 関数を使うだけで response_url での送信ができます。

Slack アプリの設定は Features > Interactivity & Shortcuts の画面で Request URL を設定した上で、グローバル・メッセージのショートカットをそれぞれ一つずつ設定します。

Name は何でもよいのですが、以下のコード例をそのまま動かしたい場合は Callback ID がそれぞれ "global-shortcut""message-shortcut" でなければ、動作しないので注意して設定してください。

では、コード例をみていきましょう。

# メッセージショートカットのリスナー
# Callback ID が "message-shortcut" の Message Shortcut を設定しておきます
@app.shortcut("message-shortcut")
def message_shortcut(ack: Ack, body: dict, respond: Respond):
    # 必ず存在します
    assert body.get("response_url") is not None

    # タイムアウトにならないように空の ack() を呼び出して 200 OK を即応答
    # 引数を渡せば、これを使って返信を投稿することもできます
    ack()

    # respond 関数にはペイロード内の response_url が自動的に設定されています
    respond("このメッセージ送信は response_url を使って送信されました!")

スラッシュコマンドとほぼ同様ですね。特に難しいことはないかとは思います。

ボタンやセレクトメニューのインタラクション

Slack アプリの設定は前のメッセージショートカットが使える状態になっていれば、それ以外に特にありません。Interactivity & Shortcuts が有効になっていて Request URL が有効になっていれば OK です。

以下は、メッセージ内のボタン、セレクトメニューの例です。この場合は response_url が必ず存在することを期待できます。

@app.command("/test-response-url")
def test_response_url(ack: Ack):
    ack(
        text="ボタンやセレクトメニューのテストです",
        blocks=[
            {
                "type": "actions",
                "elements": [
                    # これらのボタン・セレクトメニューを操作すると
                    # block_actions というイベントが発生します
                    # この場合は必ず response_url が発行されます
                    {
                        "type": "button",
                        "action_id": "button",
                        "text": {
                            "type": "plain_text",
                            "text": "クリック!",
                        },
                        "value": "start",
                    },
                    {
                        "type": "users_select",
                        "action_id": "users-select",
                        "placeholder": {
                            "type": "plain_text",
                            "text": "ユーザーを選ぶ",
                        },
                    },
                ]
            }
        ],
    )

@app.action("button")
def start(ack: Ack, body: dict, action: dict, respond: Respond):
    assert body.get("response_url") is not None
    ack()
    # メッセージ内のボタンから来たので常に response_url が存在します
    respond(f"受け取った内容: {action}", replace_original=False)

@app.action("users-select")
def start(ack: Ack, body: dict, action: dict, respond: Respond):
    assert body.get("response_url") is not None
    ack()
    # メッセージ内のセレクトメニューから来たので常に response_url が存在します
    respond(f"受け取った内容: {action}", replace_original=False)

replace_original=False を指定しているのは、ボタン・セレクトメニューを表示している元のメッセージを上書きしないためのオプションです。デフォルトは True で上書きするようになっているので False を指定しています。

このように「あなただけに表示されています」メッセージも含め、元のメッセージを上書きできるのが response_url の一つのメリットですが、この辺の詳細は最後のパートで説明しています。

一方で、ボタンやセレクトメニューを含む actions ブロックがモーダル内にある場合、チャンネルと紐づいていないため、response_url は発行されません。

@app.command("/test-response-url")
def test_response_url(ack: Ack, body: dict, client: WebClient):
    ack()
    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": "actions",
                    "elements": [
                        {
                            "type": "button",
                            "action_id": "button",
                            "text": {
                                "type": "plain_text",
                                "text": "クリック!",
                            },
                            "value": "start",
                        },
                        {
                            "type": "users_select",
                            "action_id": "users-select",
                            "placeholder": {
                                "type": "plain_text",
                                "text": "ユーザーを選ぶ",
                            },
                        },
                    ]
                }
            ],
        },
    )

@app.action("button")
def start(ack: Ack, body: dict):
    # response_url は存在しません
    assert body.get("response_url") is None
    ack()

@app.action("users-select")
def start(ack: Ack, body: dict):
    # response_url は存在しません
    assert body.get("response_url") is None
    ack()

つまり Bolt アプリで respond() を使用する block_actions イベントを発行するブロックをメッセージとモーダルで共有すると、モーダルの場合だけエラーになりますので、注意が必要です。

グローバルショートカット・モーダル送信

次は、グローバルショートカットの例をみていきましょう。Slack アプリの設定方法は先のメッセージショートカットのところを参考にしてください。

グローバルショートカットは、チャンネル内のメッセージ投稿フォームから起動できるので、直感的には response_url がペイロードに含まれていそうにも思えますが、実際はそうではありません。グローバルショートカットは、検索ウィンドウからも起動可能で、実行したユーザーが今いる場所がチャンネルであるとは限らないためです。先ほどの Block Kit の例とは異なり、こちらは起動地点に依らず、一貫して response_url が発行されません。

とはいえ、ショートカットからモーダルを開いて、処理が完了したらチャンネルに通知したいというのはよくあるユースケースでしょう。

このニーズに対応するために Slack は "response_url_enabled": true というオプションを提供しています。これのオプションを設定した conversations_select タイプのブロックをモーダル内に一つ置いておくと、選択されたチャンネルと送信したユーザーに紐づく response_url が発行されるという仕組みです。さらに "default_to_current_conversation": true というオプションを併用すると「今いる場所がチャンネルだった場合、初期値をそのチャンネルにしておく」ということもできます[7]

以下のコード例が、実際にそのようなモーダルを開く例です。

# グローバルショートカットのリスナー
# Callback ID が "global-shortcut" の Global Shortcut を設定しておきます
@app.shortcut("global-shortcut")
def global_shortcut(ack: Ack, body: dict, client: WebClient):
    # グローバルショートカットはチャンネル以外でも実行されるので
    # 特定のチャンネルに紐づく response_url はペイロードに含まれません
    assert body.get("response_url") is None

    # タイムアウトにならないように空の ack() を呼び出して 200 OK を即応答
    # スラッシュコマンドとは違って、チャンネルに紐づいていないので
    # この ack() に text / blocks を渡しても返信として投稿はされません
    ack()

    # データ送信後に指定されたチャンネルにメッセージを投稿して通知することができるモーダルを開きます
    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": "current_channel",
                    "element": {
                        "type": "conversations_select",
                        "action_id": "input",
                        # response_urls を発行するためには
                        # このオプションを設定しておく必要があります
                        "response_url_enabled": True,
                        # 現在のチャンネルを初期値に設定するためのオプション
                        "default_to_current_conversation": True,
                    },
                    "label": {
                        "type": "plain_text",
                        "text": "起動したチャンネル",
                    },
                }
            ],
        },
    )

次に、このモーダルの送信ボタンが押された時に呼び出されるリスナー関数の定義です。

受け取ったペイロード全体を示す body には response_urls というキーで配列のデータが含まれます。ただ、将来の拡張を想定して配列になってはいますが、2022 年 4 月時点では、要素は必ず一つだけになります。[8]

# モーダルからのデータ送信を受け付けるリスナー
# 上記のリスナーのように response_url_enabled: True を指定されている
# conversations_select な element を持つ input block が存在していることが前提です
@app.view("modal-id")
def view_submission(ack: Ack, body: dict):
    # response_urls は 2022 年 4 月時点では基本的に一つだけ要素を持ちます
    # モーダル内で指定されたチャンネルに紐づいた response_url が入ります
    assert body.get("response_url") is None
    assert body.get("response_urls") is not None
    ack()

    response_urls: List[Dict[str, str]] = body.get("response_urls")

    # response_urls は配列ですが、2022 年 4 月時点で要素一つを含むパターンしかありません
    # そのため、一つ目の要素を取得するコードで問題ありません
    first_channel = response_urls[0]
    # もちろん、以下のようにキーで参照しても構いません
    current_channel = [
        ru for ru in response_urls
        if ru["block_id"] == "current_channel"
    ][0]
    assert first_channel == current_channel

    # 配列の要素は dict になっていて response_url のキーで URL を取得できます
    response_url = current_channel["response_url"]

    # ここでは仕組みを説明するために手動で Respond 関数を初期化していますが、次の例のように簡潔に書くことができます
    respond = Respond(response_url=response_url)
    respond("このメッセージ送信は response_url を使って送信されました!")

この記事が書かれた 2020 年 12 月時点では Bolt はこの URL を自動的に respond() に設定しなかったため、Respond オブジェクトの初期化を利用側のコードで行う必要がありました。

2021 年 4 月にリリースされた v1.5.0 で Bolt for Python は @app.view リスナー内の respond ユーティリティに対応しました。

# モーダルからのデータ送信を受け付けるリスナー
# 上記のリスナーのように response_url_enabled: True を指定されている
# conversations_select な element を持つ input block が存在していることが前提です
@app.view("modal-id")
def view_submission(ack: Ack, body: dict, respond: Respond):
    ack()
    respond("このメッセージ送信は response_url を使って送信されました!")

ちなみに、実行したユーザーへの DM を送れば十分というユースケースなら、送信ユーザーの ID は元々データ送信リクエストのペイロードに含まれていますので、以下のようなコードで十分かもしれません。

@app.view("modal-id")
def handle(ack: Ack, body: dict, client: WebClient):
    ack()
    client.chat_postMessage(
        # chat.postMessage だけは channel に user_id を指定できます
        channel=body["user"]["id"],
        text="送信ありがとうございます!処理が完了したらお知らせします。"
    )

イベント API

最後に、イベント API の場合をみてみましょう。ユーザーとの直接的なインタラクションではない Events API では、ペイロードに response_url が含まれることはありませんrespond() 関数も実行しようとすると実行時例外になります。

Slack アプリの設定は Features > Event Subscriptions セクションで設定します。

このセクションだけは Request URL 設定時に URL の死活確認が来ます。あらかじめ SLACK_BOT_TOKENSLACK_SIGNING_SECRET を設定してアプリと ngrok を起動し、その URL がリクエストを受けられる状態にしておいてください。

では、コード例をみてみましょう。

# アプリが参加しているチャンネルのメッセージイベントのリスナー
# channels:history scope を追加して bot user をチャンネルに invite しておく必要があります
@app.event("message")
def events(body: dict, say: Say, context: BoltContext):
    # 常に存在しません
    assert body.get("response_url") is None

    # また Events API の場合は ack() を Bolt 側が自動で行う & もし手動で呼べたとしても
    # 常にチャンネルに紐づいたイベントとも限らないので ack() に text を渡して返信するといったことは
    # Bolt の仕様・実装の制約ではなく、仕組み上できません

    if context.channel_id:
        # もしチャンネルに紐づいているイベントであれば context.channel_id が存在しているはずで
        # その場合は say 関数(chat.postMessage のラッパーです)にそのチャンネルが自動で設定されています
        # (message event の場合は必ず say が使えますが、他の event の場合は常にそうとは限りません)
        say(f"<@{context.user_id}> さん!このメッセージは `say()` を使って送信しました。")

あるイベントがチャンネルで発生していて、イベントを受信したときに、そのチャンネルにメッセージを投稿したい場合、そのチャンネル ID を指定して chat.postMessage API を使用します。Bolt では、あらかじめ context.channel_id を紐づけておいてくれる say() という関数を利用すると楽でしょう。「あなただけに表示されています」なメッセージにしたい場合は chat.postEphemeral API メソッドを使うことができます。

結局 response_url を使うと何が嬉しいのか

ここまで、さまざまな具体例を見てきました。response_url の挙動はだいぶわかってきたのではないかと思います。「なるほど、仕組みは分かったけれど、どういうときに使うとよいのか?どんなメリットがあるのか?」と疑問に思われている方もいるでしょう。

ということで、最後に response_url を使うと何が嬉しいのか、3 点ポイントを紹介します。

  • 非同期で返事をできる
  • メンバーではない会話で返信ができる
  • 「あなたにだけ表示されています」なメッセージの更新ができる

ここまで理解しておけば、もう本当に完璧です!

非同期で返事をできる

コード例で度々「ack() でも返信できる」と書いてあるのを見かけたかと思います。以下のスラッシュコマンドの例を見てみてください。この二つの処理はユーザー目線だと体験は何も変わりません。どちらも「あなただけに表示されています」なメッセージを応答として返してきます。

@app.command("/request")
def test_response_url(ack: Ack):
    ack("受け付けました!")

@app.command("/request")
def test_response_url(respond: Respond):
    ack()
    respond("受け付けました!")

この ack()respond() の違いは「3 秒後以降に返信できるかどうか」です。

Slack アプリには Slack からのリクエストに 3 秒以内に応答しなければならないという仕様があります。公式ドキュメントでは、以下で説明されている内容です。

Always send an acknowledgment response.
As soon as your app receives the interaction payload, a countdown begins, because this message will self-destruct in 3 seconds. If your app doesn't respond with an HTTP status 200 OK within 3000ms of receiving the payload, the person who used the shortcut will see a generic error message letting them know that something went wrong.

https://api.slack.com/interactivity/shortcuts/using#shortcut_response

ack() は Slack からのリクエストに対して HTTP レスポンスとして、同期的に応答を返します。

一方、respond() は先ほど出てきたように 30 分以内であれば、後からでも利用できます。このように非同期で応答できるのが response_url の大きなメリットです。

ack() は何もメッセージを投稿せずに、処理が終わってから respond() で通知してもよいですし ack("受けつけました") のような一時返答だけ ack() でやって、その後 respond() を併用しても OK です。

この挙動を確かめるなら以下のようなコードを実行してみてください。スラッシュコマンド自体の実行はタイムアウトのエラー表示となり ack() で返したメッセージも反映されません。一方で respond() のメッセージは 5 秒後に正常に投稿されるはずです。

import time

@app.command("/test-response-url")
def test_response_url(ack: Ack, respond: Respond):
    time.sleep(5)
    ack("このメッセージを君が読むことはないだろう...")
    respond(text="5 秒かかってしまいましたが、お知らせです!")

このように HTTP レスポンスでのメッセージ投稿の制約を補完する形で response_url を利用することができます。

メンバーではない会話で返信ができる

例えば、自分一人だけの DM にアクセスして、そこでスラッシュコマンドを実行したとしましょう。

この DM にはあなた以外いないので、当然ながら、アプリのボットユーザーもそこにはいません。このような場合、chat.postMessagechat.postEphemeral でメッセージを投稿することはできません。ボットユーザーとして chat.* を実行するためには、そのボットユーザーが、その会話(チャンネル・DM)のメンバーである必要があるからです。そうでない場合は Web API コールに対して channel_not_found というエラーコードが返されます。[9]

しかし、スラッシュコマンドなどのユーザーインタラクションで response_url が発行された場合、その場合に限ってのみ、ボットユーザーがメンバーではないチャンネルや DM に対しても、応答としてのメッセージを残すことが可能になります。

実際に試してみましょう。以下のようなスラッシュコマンドを用意して[10]、自分自身との DM で実行してみてください。

結果は、最後の say() のメッセージだけが届かず、エラーになります。

@app.command("/run-anywhere")
def run_anywhere(ack: Ack, respond: Respond, say: Say):
    # 成功
    ack("HTTP レスポンスで送信しました!")
    # 成功
    respond("このメッセージは response_url を使って送信しました!")
    # channel_not_found エラーが返されます
    say("chat.postMessage を使っているので、このメッセージは届きません...")

見ての通り、このメリットについては response_url に限ったものではなく、HTTP レスポンスで返信するとき(Bolt でいう ack() がこれをします)にも当てはまります。

「あなたにだけ表示されています」なメッセージの更新ができる

先程少し紹介しましたが、chat.postEphemeral API を使って「あなたにだけ表示されています」なメッセージ(エフェメラルメッセージ)を送信するコードを書いたことがあるかもしれません。

この API には一つの制約があります。それは、一度送信したエフェメラルメッセージは、アプリ側の API 操作だけで書き換えたり削除したりすることができないという点です。

しかし、response_url であれば、条件によっては、エフェメラルメッセージの更新も可能です。以下の例を試してみましょう。

import time

@app.command("/test-response-url")
def test_response_url(ack: Ack, respond: Respond):
    # ここでは respond で最初のエフェメラルメッセージを投稿していますが
    # ack() で投稿してもかまいません
    ack()
    respond(
        text="テストです",
        blocks=[
            {
                "type": "section",
                "text": {"type": "mrkdwn", "text": "テストです"},
                "accessory": {
                    # このボタンを押されたイベントの response_url をテストします
                    "type": "button",
                    "action_id": "start-button",
                    "text": {
                        "type": "plain_text",
                        "text": "処理を依頼",
                    },
                    "value": "start",
                },
            }
        ],
    )

このボタンが押されたときのハンドラーです。

# ボタンが押されるとこのリスナーが呼び出されます
@app.action("start-button")
def start(ack: Ack, respond: Respond):
    ack()
    # デフォルトで replace_original=True になっています
    # 新しいメッセージとして投稿したい場合は
    # respond(text="処理中...", replace_original=False)
    respond("処理中...")

    # 時間のかかる処理
    time.sleep(3)

    # 再びメッセージを更新します
    respond("完了しました!")

このアプリを動かしてみると以下のような挙動になります。新しいエフェメラルメッセージを投稿せずに一つのメッセージが動的に切り替わっていますね。

なお、削除したい場合は delete_original=True を指定します。

一点、よくあるハマりポイントですが、これらのオプションは repsonse_url を発行するユーザーイベントがすでに存在するメッセージ内で発生している場合のみ利用可能であることに注意してください。ですので、以下のような場合に利用できるオプションではありません[11]

@app.command("/test-response-url")
def test_response_url(ack: Ack, respond: Respond):
    ack()

    # このメッセージを差し替える手段はありません
    # もしこのメッセージが blocks を使っていてボタンなどを持っている場合
    # このリスナー内ではなく、別の @app.action なリスナー内で書き換えることができます
    respond("最初のメッセージです!")

    respond(
        text="これは最初のメッセージに書き換えにならず、新規メッセージとなります...",
        replace_original=True # このオプションは無視されます
    )

replace_original/delete_original は「メッセージ内のセレクトメニューやボタンのインタラクションが発生したときに使えるオプション」と覚えておくとよいでしょう。

あと、"in_channel" な普通のメッセージについては chat.update API でも更新できるのでresponse_url が唯一の手段というわけではありませんが、こちらに対しても replace_original / delete_original は同様に動作します。

まとめ

以上です!だいぶ長い記事になってしまいましたが、「これを読んで response_url を理解できた!」という方がいれば嬉しく思います。

そして、もし response_url でわからないことが出てきたら、この記事を読み返してみてください。私が思いつく限りの疑問には全て答える記事にしたつもりです。

最後に、ポイントを振り返っておきましょう。

response_url とは、メッセージの送信に使える URL で、以下のような特徴を持っています。

  • Slack から送信されるペイロードに含まれうる
  • 特定のユーザーとチャンネルに紐づいている
  • 有効期限・利用回数上限を持っている
  • 「あなただけに表示されています」なメッセージも送信可能な URL

そして、それを利用するメリットは以下の 3 点です。

  • 非同期で返事をできる
  • メンバーではない会話で返信ができる
  • 「あなたにだけ表示されています」なメッセージの更新ができる

理解があいまいなところは、もう一度読み返してみてください。

フィードバックや質問があれば、気軽にコメントでお知らせください!それでは 👋

脚注
  1. 日本語ではおそらく初めて ↩︎

  2. ミームではなく、文字通りの意味で 😊 ↩︎

  3. 2021 年初旬に「ソケットモード」という WebSocket でこのペイロードを受け取り、応答も WebSocket 経由で返せる新しい方式がリリースされますが、その場合にも同じ構造の JSON データが送られてきます ↩︎

  4. かなり簡略化しています ↩︎

  5. 投稿したメッセージのメタデータなども response_url だけでは取得できません ↩︎

  6. 細かい話ですが slack-bolt と書いても大丈夫です。slack_bolt が本来の名前なのですが PyPI はどちらも解釈します。 ↩︎

  7. チャンネルにいないときは未設定になります ↩︎

  8. response_url_enabled: true を二つのブロックで指定すると {'ok': False, 'error': 'invalid_arguments', 'response_metadata': {'messages': ['[ERROR] response_url_enabled field can be used on a single block element in a view [json-pointer:view/blocks/1/element]']}} というエラーが返されます ↩︎

  9. 正確には chat:write.public 権限を持っていて、対象のチャンネルがパブリックチャンネルであれば、メンバーにならなくても chat.postMessage でメッセージを投稿できますが、プライベートチャンネルや DM ではそのような手段もありません。 ↩︎

  10. /test-response-url を使いまわしてもかまいません ↩︎

  11. 紛らわしいのですが、エラーにはならずにただ無視され、新しいメッセージが投稿されます ↩︎

Slack

Discussion