Bolt for Python + AWS Lambda & S3 で運用するほぼゼロコスト Slack アプリ

こんにちは、Slack の[公式 SDK 開発](https://github.com/slackapi)と日本の Developer Relations を担当している瀬良 (@seratch) と申します :wave:

[次世代プラットフォーム機能](https://api.slack.com/future)が盛り上がりつつある昨今ですが、以下の記事でも書きました通り、Bolt フレームワークも引き続きご利用いただけます。

https://qiita.com/seratch/items/ecc16b845483c9d6f9e1#bolt-%E3%81%A8-deno-sdk-%E3%81%AE%E9%81%95%E3%81%84%E3%81%AF

この記事では、Bolt for Python を使ってほぼゼロコストで運用することができる Slack アプリのデザイン例についてご紹介します。

### Bolt for Python とは？

[Bolt](https://api.slack.com/tools/bolt) とは、Slack が提供する公式の Slack アプリ開発フレームワークです。全てのプラットフォーム機能をサポートしており、Express.js のルーティング機能に似たインターフェースでリスナー関数を登録するだけで、様々なイベントに応答するインタラクティブな Slack アプリを簡単に開発することができます。

Bolt は JavaScript (Node.js)、Python、Java の三つの言語・ランタイムに対応しています。全てのドキュメントは英語以外では唯一日本語に翻訳されています。

* [Bolt for JavaScript/Node.js（日本語）](https://slack.dev/bolt-js/ja-jp/concepts)
* [Bolt for Python（日本語）](https://slack.dev/bolt-python/ja-jp/concepts)
* [Bolt for Java（日本語）](https://slack.dev/java-slack-sdk/guides/ja/)

この記事では、これらのうち [Lazy Listeners](https://slack.dev/bolt-python/ja-jp/concepts#lazy-listeners) という機能をサポートしている Bolt for Python を使ってほぼゼロコストで運用できる Slack アプリの開発について解説していきます。

なお、AWS Lambda での課題解決に Lazy Listeners が実装された経緯については、以下の記事で詳しく解説しましたので、ご興味あれば併せてお読みください。

https://qiita.com/seratch/items/6d142a9128c6831a6718

### AWS Lambda と S3 を使ってアプリを運用

普段の仕事の役に立つレベルのアプリケーションを作るとなると、単にシンプルなロジックを実行するだけでなく、必要な情報を検索したり、データを保存したりすることは必須でしょう。

また、特定のワークスペースのみで動作するアプリではなく、複数のワークスペースにインストール可能にしたい場合は、[Slack の OAuth フローを実装](https://slack.dev/bolt-python/ja-jp/concepts#authenticating-oauth)して、発行されたアクセストークンをワークスペースごとに保存し、適切に利用する必要があります。

これらを考慮すると、アプリケーションコードを実行する環境とデータストアを用意する必要がありますが、可能ならできる限りコストを抑えたいと誰しもが考えるのではないかと思います。

この記事では、それを実現するために以下の AWS サービスを利用して実装するアイデアを説明していきます。

* Slack からのリクエストは Lazy Listeners を利用したコードを API Gateway + AWS Lambda で実行して処理する
* アプリのデータを通常のデータベースではなく Amazon S3 に保存する

この組み合わせで運用すると、スラッシュコマンドで人間が実行する程度の頻度のアプリであれば、ほぼゼロコストで安定運用し続けることができます。

実際、私自身も家族との小規模な Slack ワークスペース用に作ったカスタムアプリ、デモ用に作ったアプリなどでこの設計を何度も選択していますが、それらは毎月の請求額がほぼゼロとなっています。これらのアプリは確かにユーザーは少ないのですが、もし他の環境・データベースで運用した場合、アプリごとに少なくとも月あたり 10 ドル以上のコストはかかるでしょう。

### 実装の詳細

説明を簡単にするために S3 バケットは手動で作成、コードは [Serverless Framework](https://www.serverless.com/framework/docs/providers/aws/cli-reference/deploy) を使ってデプロイしていきますが、他の方法でも全く問題ありません。

#### Amazon S3

まずは事前に S3 バケットをつくっておきます。ただし、以下に該当する場合は、このステップはスキップして構いません。

* 複数ワークスペースへのインストールをサポートしない
* 外部サービスとのコミュニケーションだけで特にこのアプリで保存するデータがない

Slack の OAuth フロー向けのバケットは以下の二つを用意します。名前はなんでも OK です。特にインストール情報を扱う方は、アクセス権限の設定を誤らないよう十二分にご注意ください。

* slack-installations: インストール時に発行されるアクセストークンをワークスペース・ユーザーなどのメタデータと共に保存するためのバケット
* slack-oauth-states: OAuth フローの state パラメーターのサーバーサイド永続化のためのバケット

どちらのバケットもデータは、オブジェクトを JSON 形式のテキストデータとしてシリアライズして保存します。異なる形式を選択したい場合は built-in のモジュールをカスタマイズして実装を変更してください。

上記のテーブルに加えて、アプリ固有のデータを保存したい場合は別途バケットを使って、アプリの中では boto (Python の AWS SDK) 経由で直接読み書きします。

#### AWS Lambda

それではいよいよアプリの実装例です。

##### Slack アプリ設定とインストール

https://api.slack.com/apps にアクセスして、以下の App Manifest (YAML 形式) を使って新しいアプリ設定を作ってください。動作確認をシンプルにするために「Run AWS Lambda App」というグローバルショートカットを実行したら、単純なモーダルが開いてデータを送信できる、という設定のみにしてあります。

```yaml
display_information:
  name: lambda-example-app
features:
  bot_user:
    display_name: lambda-example-app
  shortcuts:
    - name: Run AWS Lambda App
      type: global
      callback_id: run-aws-lambda-app
      description: Run an AWS Lambda App
oauth_config:
  scopes:
    bot:
      - commands
settings:
  interactivity:
    is_enabled: true
    # TODO: この URL は確定次第、Web 画面を使って更新します
    request_url: https://example.com/slack/events
```

アプリが作成できたら **Settings** > **Install App** のインストール画面からインストールしましょう。なお、`settings.interactivity.request_url` は、デプロイして URL が確定したら書き換えるので、今はこのままでも大丈夫です。

##### アプリのコード

次はアプリの実装です。

まずは必要なモジュールをインストールします。requirements.txt に必要なものは slack-bolt だけです。`pip install -r requirements.txt` でインストールします。

```
slack-bolt>=1.15.5,<2
```

もちろん、他のサービスと連携するならそちらが提供する SDK や requests などの汎用 HTTP クライアントも必要になります。本番環境で動作するには boto (AWS SDK) も必要ですが、AWS Lambda 環境では自動的にインストールされていますので、明に記述しなくても OK です（もちろん明記しても問題ありません）。

OAuth フローを実装しないシンプルなアプリのコードの雛形は以下の通りです。

```python
# serverless-python-requirements を使って zip 圧縮しておいた依存ライブラリの読み込み
# それを使用しない場合はここのコードは削除しても構いません
try:
    import unzip_requirements
except ImportError:
    pass

import logging
import os
from slack_bolt import App, Ack
from slack_sdk.web import WebClient

# 動作確認用にデバッグレベルのロギングを有効にします
# 本番運用では削除しても構いません
logging.basicConfig(level=logging.DEBUG)

app = App(
    # リクエストの検証に必要な値
    # Settings > Basic Information > App Credentials > Signing Secret で取得可能な値
    signing_secret=os.environ["SLACK_SIGNING_SECRET"],
    # 上でインストールしたときに発行されたアクセストークン
    # Settings > Install App で取得可能な値
    token=os.environ["SLACK_BOT_TOKEN"],
    # AWS Lamdba では、必ずこの設定を true にしておく必要があります
    process_before_response=True,
)

# グローバルショットカット実行に対して、ただ ack() だけを実行する関数
# lazy に指定された関数は別の AWS Lambda 実行として非同期で実行されます
def just_ack(ack: Ack):
    ack()

# グローバルショットカット実行に対して、非同期で実行される処理
# trigger_id は数秒以内に使う必要があるが、それ以外はいくら時間がかかっても構いません
def start_modal_interaction(body: dict, client: WebClient):
    # 入力項目ひとつだけのシンプルなモーダルを開く
    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal",
            "callback_id": "modal-id",
            "title": {"type": "plain_text", "text": "My App"},
            "submit": {"type": "plain_text", "text": "Submit"},
            "close": {"type": "plain_text", "text": "Cancel"},
            "blocks": [
                {
                    "type": "input",
                    "element": {"type": "plain_text_input"},
                    "label": {"type": "plain_text", "text": "Text"},
                },
            ],
        },
    )

# モーダルで送信ボタンが押されたときに呼び出される処理
# このメソッドは 3 秒以内に終了しなければならない
def handle_modal(ack: Ack):
    # ack() は何も渡さず呼ぶとただ今のモーダルを閉じるだけ
    # response_action とともに応答すると
    # エラーを表示したり、モーダルの内容を更新したりできる
    # https://slack.dev/bolt-python/ja-jp/concepts#view_submissions
    ack()

# モーダルで送信ボタンが押されたときに非同期で実行される処理
# モーダルの操作以外で時間のかかる処理があればこちらに書く
def handle_time_consuming_task(logger: logging.Logger, view: dict):
    logger.info(view)


# @app.view のようなデコレーターでの登録ではなく
# Lazy Listener としてメインの処理を設定します
app.shortcut("run-aws-lambda-app")(
  ack=just_ack,
  lazy=[start_modal_interaction],
)
app.view("modal-id")(
  ack=handle_modal,
  lazy=[handle_time_consuming_task],
)

# 他の処理を追加するときはここに追記してください


if __name__ == "__main__":
    # python app.py のように実行すると開発用 Web サーバーで起動します
    app.start()

# これより以降は AWS Lambda 環境で実行したときのみ実行されます

from slack_bolt.adapter.aws_lambda import SlackRequestHandler

# ロギングを AWS Lambda 向けに初期化します
SlackRequestHandler.clear_all_log_handlers()
logging.basicConfig(format="%(asctime)s %(message)s", level=logging.DEBUG)

# AWS Lambda 環境で実行される関数
def handler(event, context):
    # AWS Lambda 環境のリクエスト情報を app が処理できるよう変換してくれるアダプター
    slack_handler = SlackRequestHandler(app=app)
    # 応答はそのまま AWS Lambda の戻り値として返せます
    return slack_handler.handle(event, context)
```

##### ローカルで動作を確認する

ローカルで挙動を確認したい場合は、ngrok などのツールを使って `https://{あなたのサブドメイン}.ngrok.io/slack/events` を Request URL に設定すると疎通することができます。

ターミナルウィンドウを二つ開いて、それぞれ以下のコマンドを実行します。

```bash
# Settings > Basic Information > App Credentials > Signing Secret の値を設定
export SLACK_SIGNING_SECRET=
# Settings > Install App に表示された xoxb- から始まるトークンを設定
export SLACK_BOT_TOKEN=
# http://localhost:3000/slack/events でリクエストを処理する
python app.py
```

以下は ngrok のコマンド例です。forward する先のポートを 3000 に揃えるのを忘れずに。

```bash
# https://xxx.ngrok.io/slack/events を公開
ngrok http 3000
# もし有料プランなら固定のサブドメインを指定できます
ngrok http 3000 --subdomain your-domain
```

手元でアプリが起動したら、アプリをインストールした Slack ワークスペースを開いて、検索ウィンドウかメッセージ送信エリアのトグルから「Run AWS Lambda App」というグローバルショートカットを選択して、実行してみてください。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/f75aef7b-6f28-0702-8142-9ff97c0581d6.png" width=400 />

以下のようなモーダルが開けば、うまく動作しています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/f5164c99-f419-b390-f410-6d7676aae0ef.png" width=400 />

ローカル実行で ngrok などを使うのを避けたいという場合は、[ソケットモード](https://qiita.com/seratch/items/1a460c08c3e245b56441)を有効にした別の Slack アプリ設定をつくって、ローカルではそれを使うという手があります。その場合は、ローカル起動のコードは以下のようになります。

```python
if __name__ == "__main__":
    # python app.py のように実行すると WebSocket で接続します
    from slack_bolt.adapter.socket_mode import SocketModeHandler
    handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
    handler.start()
```

詳細は以下の記事を参考にしてください。

https://qiita.com/seratch/items/1a460c08c3e245b56441

##### AWS Lambda 環境にデプロイして動作を確認する

それでは、このアプリを AWS Lambda にデプロイして接続先を切り替えてみましょう。この記事では [Serverless Framework](https://www.serverless.com/framework/docs/providers/aws/cli-reference/deploy) というツールを使ってデプロイします。

```bash
npm install -g serverless
serverless plugin install -n serverless-python-requirements
```

後ほど serverless または sls コマンドを使用します。設定ファイルは `serverless.yml` というファイル名で以下のように設定します。

```yaml
frameworkVersion: '3'
# この名前は自由です
service: aws-lambda-app-slack
provider:
  name: aws
  # 利用可能なバージョンは https://docs.aws.amazon.com/lambda/latest/dg/lambda-python.html を確認してください
  runtime: python3.9
  # リージョンは普段使うものに変更
  region: us-east-1
  iam:
    role:
      statements:
        # Lazy Listeners を利用するために必要
        - Effect: Allow
          Action:
            - lambda:InvokeFunction
            - lambda:InvokeAsync
          Resource: "*"
        # 他にも必要なものがあれば追記してください
  environment:
    SERVERLESS_STAGE: ${opt:stage, 'prod'}
    # デプロイを実行する前に export しておいてください
    SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
    SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN}

# エンドポイント一覧を設定
# OAuth フローを設定する場合は他に二つ追加します
functions:
  app:
    handler: app.handler
    events:
      - httpApi:
          path: /slack/events
          method: post

# アップロードに含めたくないファイルは ! はじまりで列挙します
package:
  patterns:
    - "!.venv/**"
    - "!node_modules/**"
    - "!.idea/**"

# プラグインを使って依存ライブラリを zip 圧縮します
plugins:
  - serverless-python-requirements
custom:
  pythonRequirements:
    zip: true
    slim: true
```

それでは、早速デプロイしてみましょう。

```bash
# Settings > Basic Information > App Credentials > Signing Secret の値を設定
export SLACK_SIGNING_SECRET=
# Settings > Install App に表示された xoxb- から始まるトークンを設定
export SLACK_BOT_TOKEN=
# AWS Lambda にデプロイする
serverless deploy
```

正常に完了したら

```bash
(.venv) $ serverless deploy
Running "serverless" from node_modules

Deploying aws-lambda-app-slack to stage prod (us-east-1)

✔ Service deployed to stack aws-lambda-app-slack-prod (57s)

endpoint: POST - https://xxxxx.execute-api.us-east-1.amazonaws.com/slack/events
functions:
  app: aws-lambda-app-slack-prod-app (507 kB)
```

表示されている endpoint の URL をコピーして https://api.slack.com/apps の設定画面から **Features** > **Interactivity & Shorcuts** のページに移動して Request URL を書き換えます。**Save Change** という最下部の緑色のボタンを押すのを忘れずに。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/9c9340f1-acfb-1083-abc1-86aba0c59b34.png" width=600 />

同じようにグローバルショートカットを実行して、モーダルが表示されればうまく接続できているはずです。

### OAuth フローをサポートしたアプリをデプロイする

最後にこのアプリを OAuth フローで任意のワークスペースにインストールできるようにしてみましょう。

アプリのコードは App の初期化部分を以下のように書き換えます。

```python
from slack_bolt import App, Ack
from slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow import LambdaS3OAuthFlow
from slack_bolt.oauth.oauth_settings import OAuthSettings

app = App(
    # リクエストの検証に必要な値
    # Settings > Basic Information > App Credentials > Signing Secret で取得可能な値
    signing_secret=os.environ["SLACK_SIGNING_SECRET"],
    oauth_flow=LambdaS3OAuthFlow(
        settings=OAuthSettings(
            # Settings > Basic Information > App Credentials で取得可能な値
            client_id = os.environ["SLACK_CLIENT_ID"],
            # Settings > Basic Information > App Credentials で取得可能な値
            client_secret = os.environ["SLACK_CLIENT_SECRET"],
            # Settings > Features > App Manifest に表示されているものを転記するとよいです
            scopes=["commands"],
        ),
        # S3 バケット名はご自分のもので設定してください
        # 本番開発環境で分けるために環境変数などから読み込んでも良いでしょう
        oauth_state_bucket_name="my-slack-app-oauth-states",
        installation_bucket_name="my-slack-app-installations",
    ),
    # AWS Lamdba では、必ずこの設定を true にしておく必要があります
    process_before_response=True,
)
```

serverless.yml は以下のように書き換えます。provider と functions のみ変更します。変更点は S3 へのアクセス許可と OAuth 関連の URL を二点追加です。

```yaml
provider:
  name: aws
  # 利用可能なバージョンは https://docs.aws.amazon.com/lambda/latest/dg/lambda-python.html を確認してください
  runtime: python3.9
  # リージョンは普段使うものに変更
  region: us-east-1
  iam:
    role:
      statements:
        # Lazy Listeners を利用するために必要
        - Effect: Allow
          Action:
            - lambda:InvokeFunction
            - lambda:InvokeAsync
          Resource: "*"
        # Slack OAuth フローで必要
        - Effect: Allow
          Action:
            - 's3:ListBucket'
            - 's3:GetObject'
            - 's3:GetObjectAcl'
            - 's3:PutObject'
            - 's3:PutObjectAcl'
            - 's3:ReplicateObject'
            - 's3:DeleteObject'
          Resource:
            # ご自分のバケット名に書き換えてください
            - 'arn:aws:s3:::my-slack-app-oauth-states/*'
            - 'arn:aws:s3:::my-slack-app-installations/*'
  environment:
    SERVERLESS_STAGE: ${opt:stage, 'prod'}
    # デプロイを実行する前に export しておいてください
    SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
    SLACK_CLIENT_ID: ${env:SLACK_CLIENT_ID}
    SLACK_CLIENT_SECRET: ${env:SLACK_CLIENT_SECRET}

# エンドポイント一覧を設定
# OAuth フローを設定する場合は他に二つ追加します
functions:
  app:
    handler: app.handler
    events:
      - httpApi:
          path: /slack/install
          method: get
      - httpApi:
          path: /slack/oauth_redirect
          method: get
      - httpApi:
          path: /slack/events
          method: post
```

デプロイの手順も少し変わります。

```bash
# 全て Settings > Basic Information > App Credentials の値を設定
export SLACK_SIGNING_SECRET=
export SLACK_CLIENT_ID=
export SLACK_CLIENT_SECRET=
# AWS Lambda にデプロイする
serverless deploy
```

インストールしたら `https://[your domain here].execute-api.us-east-1.amazonaws.com/slack/install` にアクセスしてみましょう。以下のようなデフォルトのインストールページが表示されていれば、そのままインストールも成功するはずです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/5aa911cb-5ae0-e8cd-3318-5cf632e28fa9.png" width=300 />

インストール後、アプリを使ってみてください。何ひとつエンドユーザーの体験は変わらないはずです。

### 終わりに

いかがだったでしょうか？この方法であれば、コストを気にすることなく Slack アプリを自由に作ることができると思います。ぜひ一度試してみてください！

また、Serverless Framework 以外の良い設定方法を実践された方は、ぜひブログなどで書いていただけれると嬉しいです。

それでは！