👤

Slackへの匿名投稿機能を Slack Bolt for Python + AWS SAM で作ってみる

2023/11/16に公開

はじめに

こんにちは、D2Cエンジニアの穐澤です。

Slackで気軽に雑談したいけど、名前を出して発言するのはちょっと気が引けると感じる方も世の中には多いのではないでしょうか?
その一解決策として、本記事では匿名投稿を実現するSlackアプリを作成してみたいと思います。

匿名投稿の実現という点のみで言えば、以下記事のようにSlackのワークフロービルダーから簡単に機能を作成でき、わざわざSlackアプリを作成する必要もありません。

https://chidakiyo.hatenablog.com/entry/slack-workflow-anonymous
https://logicalbeat.jp/blog/11015/

しかし、私が最近の業務の中でサーバレスに興味を持っていたことから、今回はサーバレスの勉強も兼ねて、Slackアプリ作成用のライブラリである Slack Bolt for Python とAWS純正のサーバレス開発ツールキットである AWS SAM により匿名投稿機能を作成します。

記事の前提

本記事では以下を前提として解説していきます。準備方法等は各ツール・サービスの公式ドキュメント等を参照してください。

  • Python実行環境が準備されていること
  • SAM CLIがインストールされていて、実行できること
  • SAM CLI実行用のAWS認証情報が設定されていること

開発環境

本記事で利用している各ツールのバージョンは以下のとおりです。

  • OS: Amazon Linux 2
  • Python: 3.11.5 (Lambda関数のランタイムで python3.11 を使用するため)
  • SAM CLI: 1.97.0
$ cat /proc/version
Linux version 5.15.136-90.144.amzn2.x86_64 (mockbuild@ip-10-0-61-37) (gcc10-gcc (GCC) 10.5.0 20230707 (Red Hat 10.5.0-1), GNU ld version 2.35.2-9.amzn2.0.1) #1 SMP Tue Oct 24 14:40:41 UTC 2023

$ python -VV
Python 3.11.5 (main, Sep 30 2023, 05:19:40) [GCC 7.3.1 20180712 (Red Hat 7.3.1-15)]

$ sam --version
SAM CLI, version 1.97.0

また、Slack Bolt, Slack SDKのPythonライブラリについては、以下のバージョンを利用しました。

  • slack-bolt: 1.18.0
  • slack-sdk: 3.23.0

全体構成・実装

今回用意するリソースの全体構成は以下のとおりです。

典型的なAPI Gateway + Lambdaのシンプル構成です。専用のSlackアプリを作成し、API GatewayのエンドポイントをリクエストURLに設定したショートカットを実行することで、アプリのBot userがメッセージを投稿します。

では、順番にリソースを作成していきます。

サーバレスアプリケーションの構築

まず、AWS SAMを用いてサーバレスアプリケーション(以後、SAMアプリケーションと呼びます)を構築します。

作成したソースコードの構成は以下のとおりです。
sam init を実行し、AWS Quick Start Templates > Hello World Example を選択して作成されたファイルを流用しており、不要なものは削除しています。

ディレクトリ構成
.
|-- anonymous_posting
|   |-- __init__.py
|   |-- app.py
|   `-- requirements.txt
|-- samconfig.toml
`-- template.yaml

__init__.py は空ファイルです。
また、requirements.txt には、先述した slack-bolt, slack-sdk のバージョンを記載します。

requirements.txt
slack-bolt==1.18.0
slack-sdk==3.23.0

これにより、デプロイ時にSAMが依存ライブラリをまとめてパッケージングしてくれます。

以下、app.pytemplate.yamlsamconfig.tomlのそれぞれについて内容とポイントを説明します。


app.py (Slack Boltを用いてSlackとインタラクティブにやり取りする)

コードの全体は以下のとおりです。(長いため折りたたんでいます)

anonymous_posting.py
anonymous_posting/app.py
import os
import logging


from slack_bolt import App, Ack, Say, Respond
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
from slack_sdk.web import WebClient

logging.basicConfig(level=logging.DEBUG)


app = App(
    signing_secret=os.environ["SLACK_SIGNING_SECRET"],
    token=os.environ["SLACK_BOT_TOKEN"],
    process_before_response=True,
)


def just_ack(ack: Ack):
    ack()


# 新規投稿用のモーダルを開く
def open_new_post_modal(body: dict, client: WebClient):
    modal_view = {
        "type": "modal",
        "callback_id": "new_post_modal",  # あとでapp.view()に渡す
        "title": {"type": "plain_text", "text": "新規の匿名メッセージ"},
        "submit": {"type": "plain_text", "text": "送信"},
        "close": {"type": "plain_text", "text": "キャンセル"},
        "blocks": [
            {
                "type": "input",
                "block_id": "new_post_text_block",
                "element": {
                    "type": "rich_text_input",
                    "action_id": "new_post_text",
                },
                "label": {
                    "type": "plain_text",
                    "text": "投稿内容",
                    "emoji": True,
                },
            },
            {
                "type": "input",
                "block_id": "channel_to_post_block",
                "element": {
                    "type": "conversations_select",
                    "action_id": "channel_to_post",
                    "response_url_enabled": True,
                    "default_to_current_conversation": True,
                },
                "label": {
                    "type": "plain_text",
                    "text": "投稿先チャンネル",
                },
            },
        ],
    }

    client.views_open(trigger_id=body["trigger_id"], view=modal_view)


# "open_new_post_modal" は、Slackアプリのショートカットのcallback_idとして指定する
app.shortcut("open_new_post_modal")(
    ack=just_ack,
    lazy=[open_new_post_modal],
)


def modal_ack(ack: Ack):
    ack()


# 新規投稿モーダルから入力を受け取り投稿する
def send_new_post(
    view: dict, respond: Respond, logger: logging.Logger
):
    inputs = view["state"]["values"]
    text_to_post = (
        inputs.get("new_post_text_block").get("new_post_text").get("rich_text_value")
    )
    logger.info(f"text: {text_to_post}")

    respond(blocks=[text_to_post], response_type="in_channel")

# "new_post_modal" は、open_new_post_modal()でモーダルのcallback_idに指定した値
app.view("new_post_modal")(
    ack=modal_ack,
    lazy=[send_new_post],
)

SlackRequestHandler.clear_all_log_handlers()
logging.basicConfig(
    format="%(asctime)s %(levelname)s %(name)s :%(message)s", level=logging.DEBUG
)


def lambda_handler(event, context):
    slack_handler = SlackRequestHandler(app=app)
    return slack_handler.handle(event, context)

動作概要を説明すると、以下のような流れになります。

  1. Slackのショートカットアイコンから投稿用ショートカット(後ほど作成)が選択されたら、投稿内容を入力するモーダルを表示する(open_new_post_modal())
  2. モーダルの送信ボタンが押されたら、モーダルで入力された内容を指定のチャンネルにbot userとして投稿する(send_new_post())

入力モーダルについては、Slackが提供している Block Kit Builder を使って構成しました。今回は、プレーンテキスト入力だけでなく、普段の入力欄と同じようにリッチテキストを受け付けるようにしています。


入力モーダルサンプル

"open_new_post_modal" をcallback idとするショートカットをトリガーに、open_new_post_modal() を実行して client.views_open を呼び出すことで、モーダルをユーザのSlack画面上に表示することができます。そして send_new_post() でモーダルから送信されたデータをハンドリングしたのち、respond を呼び出して指定のチャンネルにメッセージを投稿します。これによって、各ユーザがアプリのbot userの名前を借りて匿名で投稿することが可能になります。
モーダルの使い方については以下記事を参考にしました。
https://qiita.com/seratch/items/0b1790697281d4cf6ab3

また、Slackは3秒以内にレスポンスを返さなければならないため、Lazy Listenersの機能を利用しています。
Lazy Listenersの使用例については以下記事を参照してください。
https://qiita.com/seratch/items/12b39d636daf8b1e5fbf


template.yaml (必要なAWSリソースを作成する)

template.yaml
template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Anonymous posting in Slack

Parameters:
  SlackSigningSecret:
    Type: String
    Default: ""
  SlackBotToken:
    Type: String
    Default: ""


Resources:
  LambdaRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: anonymous-posting-lambda-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: allow-lambda-invocation
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - lambda:InvokeFunction
                  - lambda:InvokeAsync
                Resource: "*"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

  AnonymousPostingFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: anonymous_posting/
      Handler: app.lambda_handler
      Runtime: python3.11
      Role: !GetAtt LambdaRole.Arn
      Timeout: 30
      MemorySize: 256
      Architectures:
        - x86_64
      Environment:
        Variables:
          SLACK_SIGNING_SECRET: !Ref SlackSigningSecret
          SLACK_BOT_TOKEN: !Ref SlackBotToken
      Events:
        Slack:
          Type: HttpApi
          Properties:
            Method: POST
            Path: /slack
            TimeoutInMillis: 10000
            PayloadFormatVersion: "2.0"
            RouteSettings:
              ThrottlingBurstLimit: 100
              ThrottlingRateLimit: 200

  AnonymousPostingFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${AnonymousPostingFunction}
      RetentionInDays: 14

特に重要なポイントは以下の3点です。

  • Lazy Listenersを利用するため、Lambda関数用ロールに非同期Lambda実行を開始する権限(lambda:InvokeFunctionlambda:InvokeAsync) を付与する。
  • Lambdaの環境変数(SLACK_SIGNING_SECRETSLACK_BOT_TOKEN)で、後ほど作成するSlackアプリの認証情報を受け取れるようにする。環境変数の値は、デプロイ時にパラメータで与える。
  • Lambda関数の Events プロパティにAPIのパスを指定する。これにより、API Gatewayが自動で作成される。このとき、メソッドはPOSTを指定する必要がある(Slackからのリクエスト内容を受け付けるため)。

SAM公式リファレンスと以下の記事を参考に、各プロパティを設定しています。
https://qiita.com/seratch/items/12b39d636daf8b1e5fbf

また他に言及すべき点として、Lambda関数用のロググループ (AnonymousPostingFunctionLogGroup) を明示的に作成しています。LambdaがCloudWatch Logsへの書き込み権限を持っている場合、Lambdaは /aws/lambda/{関数名} というロググループにログを出力します。このときロググループが存在していない場合は自動で作成されるのですが、このロググループはSAMアプリケーション外に作成されるため、アプリケーション削除後も残ってしまいます。そこで、SAMテンプレート内でロググループを作成しておくことで、後でリソースのお掃除をする際にまとめて削除できるようにしておきます(実運用では、アプリケーション削除後もログを一定期間保持するのが一般的だと思います)。
また、ログ書き込みの権限(AWSLambdaBasicExecutionRole) もLambda関数用ロールに付与しています。


samconfig.toml (SAM CLIの実行設定)

samconfig.toml
  # More information about the configuration file can be found here:
  # https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/  serverless-sam-cli-config.html
  version = 0.1
  
  [default]
  [default.global.parameters]
  stack_name = "anonymous-posting-lambda"
  
  [default.build.parameters]
  cached = true
  parallel = true
  
  [default.validate.parameters]
  lint = true
  
  [default.deploy.parameters]
- capabilities = "CAPABILITY_IAM"
+ capabilities = "CAPABILITY_NAMED_IAM"
  confirm_changeset = true
- resolve_s3 = true
+ s3_bucket = "my-bucket"
  
  [default.package.parameters]
- resolve_s3 = true
+ s3_bucket = "my-bucket"
  
  [default.sync.parameters]
  watch = true
  
  [default.local_start_api.parameters]
  warm_containers = "EAGER"
  
  [default.local_start_lambda.parameters]
  warm_containers = "EAGER"

SAM CLIを実行する際の設定を記述します。sam init を実行して作成された内容から以下の2点を変更しています。

  • デプロイ時のcapabilitiesCAPABILITY_NAMED_IAM に設定 (CloudFormationによる自動生成ではなく、指定したロール名でIAMロールを作成するため)
  • アプリケーションアーティファクトのアップロード先S3バケットを指定

今回はローカルでのテスト等を行わないため、他の設定はそのままです。

ここまででSAMアプリケーションのデプロイ準備が整いました。ではSAM CLIを実行してビルド・デプロイしましょう。ここでは、パラメータでSlackアプリの認証情報を与えることはせずにデプロイします。

$ sam build
$ sam deploy --profile xxx --region ap-northeast-1

デプロイが成功したらコンソールを開き、作成されたリソースを見てみましょう。SAMテンプレート内で明示的に作成したLambda関数、IAMロール、ロググループの他に3つのリソースが自動で作成されています。

  • API Gatewayの本体 (ServerlessHttpApi)
  • API Gatewayのデフォルトステージ (ServerlessHttpApiApiGatewayDefaultStage)
  • Lambda関数のリソースポリシー (AnonymousPostingFunctionSlackPermission)
    • API GatewayからのLambda実行を許可している

CloudFormationコンソール

しかし、まだSlackアプリの認証情報を与えていないため、このままではこのアプリケーションは動きません。
次節でSlackアプリ作成後、認証情報をアプリケーションに与えてデプロイし直します。

Slackアプリの作成

続いて、メッセージの投稿に用いるSlackアプリを作成します。
1つずつ手で設定をしていくこと(From Scratch)も可能ですが、今回は設定作業を省力化するためApp Manifestを使って作成していきます。使用したmanifestを以下に示します。

app manifest
display_information:
  name: anonymous-posting
features:
  app_home:
    home_tab_enabled: false
    messages_tab_enabled: false
    messages_tab_read_only_enabled: false
  bot_user:
    display_name: anonymous-posting
    always_online: true
  shortcuts:
    - name: 匿名の新規メッセージを投稿する
      type: global
      callback_id: open_new_post_modal
      description: チャンネルで匿名のメッセージを送信します
oauth_config:
  scopes:
    bot:
      - commands
      - chat:write
settings:
  interactivity:
    is_enabled: true
    request_url: https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/slack
  org_deploy_enabled: false
  socket_mode_enabled: false
  token_rotation_enabled: false

ポイントは以下の3点です。

  • features > shortcuts で入力モーダルを開くグローバルショートカットを作成する
    • callback_id の値は app.py で参照する
  • oauth_config でbot userにショートカット実行権限とチャンネルへの投稿権限を付与する
  • settings > interactivity で先ほど作成したAPI Gatewayのエンドポイントを指定する

スラッシュコマンドではなくショートカットを採用している理由は、Slackは入力欄に入力中のユーザが表示される仕様であり、スラッシュコマンドによる入力では匿名性が失われるためです。

アプリを作成したら、slack apiの画面から「Install App」 > 「Install Workspace」を選択し、ワークスペースにアプリをインストールします。インストールすると、xoxb-で始まるトークンが発行されます。このトークンは後ほどSAMアプリケーションに渡します。

作成したアプリをチャンネルに追加しておきましょう。今回は専用のチャンネルを作成し、そちらにアプリを追加します。アプリ追加は、チャンネル詳細を開いて「インテグレーション」タブから行うことができます。

SAMアプリケーションの再デプロイ

前節で作成したSlackアプリの認証情報(signing secret、bot token)を sam deploy 時のパラメータとして与え、SAMアプリケーションを再デプロイしましょう。signing secretはslack api画面の「Basic Information」から取得できます。bot tokenについては、「Install App」からBot User OAuth Tokenの内容をコピーしてください。

$ sam deploy --profile xxx --region ap-northeast-1 \
    --parameter-overrides SlackSigningSecret=YYYYYYYYYYYYYYY SlackBotToken=xoxb-XXXXXXXXXXXXXXXXXX

ここまででようやく匿名投稿機能が完成しました。

動作確認

では、作成したSlackアプリを用いて実際にメッセージを投稿してみましょう。
ショートカットアイコンから、Slackアプリで設定したショートカットを選択します。


赤枠内がショートカットアイコン

投稿内容を入力して「送信」ボタンを押すと、入力した内容がSlackアプリからチャンネルに投稿されました🎉
ファイルや画像の添付はできませんが、普段のマークダウン記法も使用可能です。

リソース削除

作成したSAMアプリケーションを削除する場合は sam delete を実行してください。また、Slackアプリはslack api画面の「Basic Information」ページ最下部から削除できます。

感想

AWS SAMを利用することでAPI Gateway + Lambdaのサーバレスアーキテクチャを簡単に構成でき、非常に便利だと感じました。今回のように、社内ツールなどの開発・運用コストを抑えた小規模アプリケーションを作成する際には、今後も活用していければと思います。

また、Slackとアプリケーションのインタラクティブなやり取りを簡単に実現することができる点が、Slack Boltの強みのようです。本記事で紹介しているQiita記事の筆者の方はSlack Bolt等公式SDKの開発に携わられているようで、ご本人の記事も含め日本語の情報が充実しています。SDKのユースケースやSlackの追加機能などを網羅的に発信されているようなので、ご興味のある方はぜひ記事を読んでみてください。

最後に

本記事では、Slack Bolt for PythonとAWS SAMを用いて、匿名投稿をするSlackアプリを作成する方法を紹介しました。本記事が何かのお役に立てれば幸いです。

なお、今回作成した匿名投稿機能ですが、CloudWatchに出力されたログを見れば送信者がわかってしまうため、匿名なのはあくまでSlack画面上だけです。この点については、送信者情報をログに出力しないようにするか、管理者の善意を信頼するかのどちらかになると思います。実際に社内で使う上では、このあたりの運用を考える必要もありそうです。

また実装を進める中で、匿名メッセージに匿名で返信するといった他の機能もあるといいなとも感じました。追加機能を作成することができたら、改めて記事を書いてみようかと思います。

最後までお読みいただき、ありがとうございました。

D2C m-tech

Discussion