Cloud Run functionsでSlackのスラッシュコマンドを作ってみた

記事タイトルとURLをコピーする

G-gen の杉村です。Google Cloud の Cloud Run functions を使い、Slack のスラッシュコマンドを作ってみました。主に Google Cloud 側の開発に関する概要を解説します。

はじめに

当記事について

Google Cloud(旧称 GCP)の Cloud Run functions で Slack のスラッシュコマンドを開発した際の事例を紹介します。なお当記事では Slack アプリの設定方法の詳細などは解説せず、Cloud Run functions 側のソースコードを中心に解説します。

Cloud Run functions(旧称 Cloud Functions)は、サーバーレスなプログラム実行基盤です。ソースコードを開発してアップロードするだけで、最長60分間(第2世代、HTTP 関数の場合)のプログラム実行が可能です。詳細は以下の記事をご参照ください。

blog.g-gen.co.jp

スラッシュコマンドとは、Slack から / で始まるコマンドを実行することで、様々なタスクを実行できる Slack の機能です。組み込み済みのスラッシュコマンドの例として、指定した時間にユーザーにリマインダー通知をしてくれる /remind などがあります。ユーザーはバックエンドプログラムを用意することで、独自のカスタムコマンドを定義できます。

免責事項

当記事で紹介するプログラムのソースコードは、ご自身の責任のもと、使用、引用、改変、再配布して構いません。

ただし、同ソースコードが原因で発生した不利益やトラブルについては、当社は一切の責任を負いません。

当記事で紹介するプログラムのソースコードは検証のために作成されたものであり、セキュリティやエラーハンドリング等の運用性が十分考慮されたものではありません。利用する際はこれらを十分考慮の上、ご自身の状況にあわせてご利用ください。

構成

構成図

当記事では、以下のような環境を開発しました。

Slack にはアプリを設定し、スラッシュコマンドから呼び出せるように設定します。スラッシュコマンドのバックエンドプログラムとして、Slack からのリクエストを受け取る「レシーバー関数」を配置します。レシーバー関数はメッセージキューである Pub/Sub トピックにメッセージを送信します。メッセージを検知すると、「バックエンド関数」が起動します。バックエンド関数はメインの処理を行い、Slack に返信を返します。

構成図

レシーバー関数とバックエンド関数を分ける理由

途中にメッセージキューを挟み、レシーバー関数とバックエンド関数を分けている理由は、スラッシュコマンドのタイムアウト仕様にあります。スラッシュコマンドは、バックエンドプログラムに処理を送ってから3秒以内に HTTP 200のレスポンスを受け取らないと、タイムアウトしてエラー終了します。

これを防ぐため、まずレシーバー関数でスラッシュコマンドを受け取り、いったんメッセージキューに処理要求を送ってから、Slack に HTTP 200を返します。これにより、バックエンド関数は3秒以上の処理を行うことができます。なおバックエンド関数は返信先のチャンネルやユーザーを、メッセージ内の情報から知ることができます。

なお、Cloud Run functions がコールドスタート(ゼロスケールから最初のコンテナインスタンスが起動するまでの遅延)する際に3秒のタイムアウトに抵触する場合があります。コールドスタート時の初回レスポンスを早くするには、function の割り当て CPU 量を増やしたり、重い処理を避けたり、Python の場合は不要な import を避けるなどの対策をしてください。

Slack の署名検証

Slack のスラッシュコマンドがバックエンドプログラムに HTTP リクエストを送信するとき、Slack は Signing Secret を使ってリクエストを署名し、x-slack-signature ヘッダーに格納します。Signing Secret は Slack アプリごとに発行されます。この署名をバックエンドプログラム側(当環境ではレシーバー関数)で検証することで、リクエストが確かに想定した Slack アプリから発信されていることを確かめられます。これにより、想定しない利用者からのリクエストを拒否することができます。

当環境では、Signing Secret を Secret Manager に格納して Cloud Run functions から参照しています。Signing Secret は、Slack アプリ作成後に「Basic Information」欄から取得できます。

この署名検証があるため、Cloud Run functions に認証をかける必要はありません。Cloud Run functions は未認証リクエスト許可する HTTP トリガー関数としてデプロイします。

Slack へのメッセージ返信

Slack からバックエンドプログラムに送信される HTTP リクエストには、channel_id、user_id、command(スラッシュコマンドのコマンドライン)、text(コマンドラインに付加されたパラメータ)などに加え、response_url と呼ばれる HTTP エンドポイント URL が含まれています。

この response_url は、スラッシュコマンドが実行されるたびにユニークに発行される一時的な URL です。30分間のみ有効で、この URL に対して POST リクエストを送信することで、認証なしで、スラッシュコマンドの実行者にだけ見える形式で表示されます(「あなただけに表示されています」)。

後者が response_url で投稿されたコメント

当環境ではこの response_url を使い、Slack の認証情報(トークン)を保持することなく、バックエンド関数から Slack にコメントを投稿しています。

なお全員から見える通常のコメントとして返信したい場合は、OAuth トークンを使い認証したうえでコメントを投稿します。

レシーバー関数の開発

Slack からの HTTP リクエスト

Slack のスラッシュコマンドからレシーバー関数に送られる HTTP POST リクエストの body は、以下のようになります(Slack 公式ドキュメントから引用)。

token=gIkuvaNzQIHg97ATvDxqgjtO
&team_id=T0001
&team_domain=example
&enterprise_id=E0001
&enterprise_name=Globular%20Construct%20Inc
&channel_id=C2147483705
&channel_name=test
&user_id=U2147483697
&user_name=Steve
&command=/weather
&text=94070
&response_url=https://hooks.slack.com/commands/1234/5678
&trigger_id=13345224609.738474920.8088930838d88f008e0
&api_app_id=A123456

このうち、command=/weather の部分が入力されたコマンド本体で、text の部分がそれに続いて入力された引数です。response_url が、返信に使える response_url であり、この URL に以下のように POST リクエストを送ることで、チャンネルに返信することができます。

POST https://hooks.slack.com/commands/1234/5678
Content-type: application/json
{
    "text": "返信内容のテキスト"
}

ソースコード

以下は、レシーバー関数のソースコードです。前掲の「免責事項」をご確認のうえご利用ください。

なお、ソースコードは Python 3.12.3 の環境で開発されています。

以下で、重要なポイントのみを解説します。

Pub/Sub クライアントの生成

28-30行目

# 環境変数の取得、Pub/Sub クライアントの生成
PROJECT_ID = os.environ.get("PROJECT_ID", None)
PUBSUB_CLIENT = google.cloud.pubsub_v1.PublisherClient()

PUBSUB_CLIENT をグローバルスコープで生成しています。コストが高い代わりに結果を再利用できる処理は、グローバルスコープで実行することで、Cloud Run functions のコンテナインスタンスが残っている限り再利用できるため、パフォーマンスが向上します。

Slack からの署名を検証

33-40行目

# Slack からの署名を検証する関数
def verify_signature(request):
    request.get_data()
  
    verifier = SignatureVerifier(os.environ["SLACK_SIGNING_SECRET"])
  
    if not verifier.is_valid_request(request.data, request.headers):
        raise ValueError("Invalid request/credentials.")

Slack からの署名を検証するために、関数 verify_signature を呼び出しています。同関数では、Cloud Run functions 環境変数 SLACK_SIGNING_SECRET を取得し、POST リクエストの署名を検証して、正当な署名を確認できなければ ValueError を返します。

SLACK_SIGNING_SECRET は環境変数に直接定義するのではなく、Secret Manager から取得するのが望ましいでしょう。

なお、この関数の実装は Google Cloud が公開するチュートリアルドキュメントとソースコードを参考にしました。使用するライブラリは Slack 公式の slackclient です。

また、この関数のを呼び出し元の50-62行目では、テストモードのときに例外を無視するコードを入れています。これは functions_framework を使いローカルでテストする場合を想定しています。

blog.g-gen.co.jp

Slack からのリクエスト body を取得

64-66行目

    # Slack からのリクエストを辞書型変数と JSON に格納
    body_dict = request.form.to_dict(flat=True)
    body_json = json.dumps(body_dict)

この部分で、POST リクエストの body を辞書型変数および JSON 形式の文字列として変数に格納しています。このレシーバー関数では、Slack からのリクエスト body を JSON 形式にしてそのまま Pub/Sub にパブリッシュしています。JSON は、のちにバックエンド関数がパースして処理に使います。

環境変数からトピック ID を取得

69-72行目

    # コマンド名に応じた環境変数から Pub/Sub トピック ID を取得
    command = body_dict["command"].lstrip("/").replace("-", "_").upper()
    ENV_NAME = f"TOPIC_ID_{command}"
    TOPIC_ID_FOR_COMMAND = os.environ.get(ENV_NAME, None)

レシーバー関数を、複数のスラッシュコマンドとバックエンド関数のために使い回せる汎用的なものにするために、リクエスト内のスラッシュコマンドに応じた Pub/Sub トピック ID を取得できるようにしています。規模が大きくなればコマンド名とトピック ID の対応を Firestore などのデータベースに保持しても良いですが、ここでは簡略化のために環境変数に保持しています。

エラーメッセージ

74-77行目

    # 環境変数が定義されていない場合はエラー終了。Slack にメッセージを返すため 200 で終了
    if TOPIC_ID_FOR_COMMAND is None:
        logger.exception(f"環境変数 {ENV_NAME} が設定されていません。")
        return f":warning: コマンド {body_dict['command']} は未対応です。", 200

87-89目

    except Exception as e:
        # Slack にメッセージを返すため 200 で終了
        logger.exception(e) 
        return f":bomb: 予期せぬエラーが発生しました。エラーメッセージ : {e}", 200

ここで、エラー(例外)であるにも関わらずレスポンスのステータスコードとして 200 を使用しているのには理由があります。Slack のスラッシュコマンドでは、バックエンドプログラムが 200 以外のステータスコードを返した場合、関数からのレスポンスは Slack チャンネル側に表示されず、/(コマンド名) はエラー「dispatch_failed」により失敗しました というメッセージに統一されてしまいます。

ここでは Slack 側に任意のメッセージを返すために、ステータスコードを 200 としています。逆に認証エラーやメソッド違反の場合は、Slack にメッセージを返すことが適切ではありませんので、400番台のステータスコードを返しています。

ステータスコード 200 でレスポンスした場合

ステータスコード 400 でレスポンスした場合

なお上記のスクリーンショットからわかるように、バックエンドプログラムからのレスポンスはコマンドの実行者にだけ見える形式で表示されます(「あなただけに表示されています」)。

バックエンド関数の開発

Pub/Sub トリガー関数のデプロイ

レシーバー関数が Pub/Sub にパブリッシュしたメッセージをトリガーとしてバックエンド関数を起動するには、バックエンド関数を Pub/Sub トリガーの関数としてデプロイします。gcloud コマンドでのデプロイ方法を例に挙げると、以下のようになります。

FUNCTION="backend-sample"
PROJECT_ID="my-project"
TOPIC_ID="backend-sample"
TRIGGER_SA="receive-slash-command"
  
gcloud functions deploy ${FUNCTION} \
  --gen2 \
  --project=${PROJECT_ID} \
  --region=asia-northeast1 \
  --runtime=python312 \
  --memory=128Mi \
  --entry-point=main \
  --trigger-topic=${TOPIC_ID} \
  --trigger-service-account="${TRIGGER_SA}@${PROJECT_ID}.iam.gserviceaccount.com"

このコマンドを使ってデプロイすると、Eventarc トリガーというオブジェクトが作成されます。この Eventarc トリガーが、--trigger-topic で指定した Pub/Sub トピックとCloud Run functions を仲介して、関数を起動します。Eventarc トリガーは --trigger-service-account で指定したサービスアカウントの認証情報を利用して関数を起動しますので、このサービスアカウントには Cloud Run サービス起動元(roles/run.servicesInvoker)ロールが必要です。ここでは、レシーバー関数にアタッチしたサービスアカウントを使うように指定していますが、独立したサービスアカウントを作成しても構いません。

Pub/Sub から受け取るメッセージ

バックエンド関数は、以下のようなメッセージを Pub/Sub から受信し、main 関数の引数の cloud_event として受け取ります。

[
    {
        "ackId": "BhYsXUZIUTcZCGhRDk9eIz81IChFFwkDxxxxx....",
        "message": {
            "data": "ewogICJ0b2tlbiI6ICJnSWt1dmFOelFJSGc5N0FUdkR4cWdqdE8iLAogICJ0ZWFtX2lkIjogIlQw
MDAxIiwKICAidGVhbV9kb21haW4iOiAiZXhhbXBsZSIsCiAgImNoYW5uZWxfaWQiOiAiQzIxNDc0
ODM3MDUiLAogICJjaGFubmVsX25hbWUiOiAidGVzdCIsCiAgInVzZXJfaWQiOiAiVTIxNDc0ODM2
OTciLAogICJ1c2VyX25hbWUiOiAiU3RldmUiLAogICJjb21tYW5kIjogIi93ZWF0aGVyIiwKICAi
dGV4dCI6ICI5NDA3MCIsCiAgImFwaV9hcHBfaWQiOiAiQTEyMzQ1NiIsCiAgImlzX2VudGVycHJp
c2VfaW5zdGFsbCI6ICJmYWxzZSIsCiAgInJlc3BvbnNlX3VybCI6ICJodHRwczovL2hvb2tzLnNs
YWNrLmNvbS9jb21tYW5kcy8xMjM0LzU2NzgiLAogICJ0cmlnZ2VyX2lkIjogIjEzMzQ1MjI0NjA5
LjczODQ3NDkyMC44MDg4OTMwODM4ZDg4ZjAwOGUwIiwKfQo=",
            "messageId": "13511712435836909",
            "publishTime": "2025-01-12T05:21:28.259Z"
        }
    }
]

data の内容は base64 エンコードされています。上記の data を base64 でデコードすると、以下のような JSON になります。Slack からのリクエスト body が、そのまま JSON になっています。バックエンド関数では、これをパースすればよいことになります。

{
    "token": "gIkuvaNzQIHg97ATvDxqgjtO",
    "team_id": "T0001",
    "team_domain": "example",
    "channel_id": "C2147483705",
    "channel_name": "test",
    "user_id": "U2147483697",
    "user_name": "Steve",
    "command": "/weather",
    "text": "94070",
    "api_app_id": "A123456",
    "is_enterprise_install": "false",
    "response_url": "https://hooks.slack.com/commands/1234/5678",
    "trigger_id": "13345224609.738474920.8088930838d88f008e0",
}

ソースコード

以下は、バックエンド関数のソースコードです。前掲の「免責事項」をご確認のうえご利用ください。

以下、重要なポイントのみ解説します。

Pub/Sub メッセージの取得

28-32行目

@functions_framework.cloud_event
def main(cloud_event):
    Pub/Sub から受け取ったメッセージの data を JSON として取得して辞書型に変換
    request = base64.b64decode(cloud_event.data["message"]["data"]).decode()
    request_dict = json.loads(request)

ここでは前述のとおり、Pub/Sub から受け取ったメッセージを base64 デコードして、辞書型変数として格納しています。

response_url への返信

45-48行目

        # response_url に対して返信
        headers = {'Content-type': 'application/json'}
        data = {'text': post_text}
        requests.post(response_url, headers=headers, data=json.dumps(data))

ここでは、response_url に対して HTTP POST リクエストを送信して、Slack チャンネルにコメントを投稿しています。Content-typeapplication/json にし、{'text': '投稿する内容'} とするのが決まりです。response_url は30分間限定で認証なしでコメントを受け付ける URL なので、Slack の認証情報は必要ありません。

前者がレシーバー関数からのレスポンス、後者がバックエンド関数から投稿されたコメント

ただし、この response_url を使って送信された返信は、コマンド実行者にだけ「あなただけに表示されています」と表示されるメッセージです。チャンネル全体に見えるコメントを投稿するには、OAuth トークンを使って Slack API に対してコメントを投稿します。その方法はこの記事では詳細は解説しませんが、公式ドキュメント等をご参照ください。

Slack 側の設定

Slack 側の設定については、インターネット上に多くの解説があるため、当記事では深く触れません。大まかに言うと、以下の手順となります。

  1. Slack アプリを作成(作成後、Signing Secret 等が取得可能になる)
  2. アプリにスラッシュコマンドを追加
  3. Slack ワークスペースにアプリをインストール

以下の公式ドキュメントも参考にしてください。

杉村 勇馬 (記事一覧)

執行役員 CTO / クラウドソリューション部 部長

元警察官という経歴を持つ現 IT エンジニア。クラウド管理・運用やネットワークに知見。AWS 認定資格および Google Cloud 認定資格はすべて取得。X(旧 Twitter)では Google Cloud や Google Workspace のアップデート情報をつぶやいています。