🍣

複数ユーザーに対応したSlack Bot を Kotlin + Serverless Framework + AWS Lambda で作る

2023/11/17に公開

Slack Botを複数のワークスペースで使いたかったり、Slack連携できるようなサービスを開発している場合があります。

そういうケースにおいて、どのように実装すればいいのか解説していきます。

複数ユーザーに対応する必要ない場合のものについては、下記で説明しています。
流れとしては大きく変わらないため、可能ならば下記も一読してからこちらの記事を読んでください。

https://zenn.dev/mobdev/articles/b50a6da59f5071

はじめに

前提

別途ダッシュボードなどが用意されており、DynamoDB に 各ユーザーの Bot TokenSigning Secret が保存されているとします

方針

エンドポイントを https://xxxxxx/slack/events/{userId} というふうにして、 userId をもとに保存している Bot TokenSigning Secretcom.slack.api.bolt.App に設定できるようにします

使用技術

今回の使用技術は以下のようになっています。ツール系のインストールなどは完了しているとします。

技術 説明 URL
Slack Bolt for Java Slack の Java用の SDK リンク
Amazon API Gateway + AWS Lambda APIのエンドポイントおよびAPIの実装をここで -
Dynamo DB Slack の Bot Token や Signing Secret の保存先を想定 -
Serverless Framework サーバーレスアプリケーションの開発、デプロイ、管理ツール リンク
Kotlin プログラミング言語 リンク

実装していく

Kotlin のプロジェクトを立ち上げる

Gradle プロジェクトを作成して、 Bolt 関連と lambda 関連の依存ライブラリを build.gradlebuild.gradle.kts に追加します.

dependencies {
  implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
  implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
  implementation("com.slack.api:bolt-jetty:1.34.0")
  implementation("com.slack.api:bolt-aws-lambda:1.34.0")
  implementation("org.slf4j:slf4j-simple:1.7.36")
  implementation("com.amazonaws:aws-lambda-java-core:1.2.1")
  implementation("com.amazonaws:aws-java-sdk-lambda:1.11.907")
}

メンションに反応して返信する実装をする

今回は簡単にメンションがあれば Hello と返す Bot とします.

com.slack.api.bolt.App のインスタンスを作成して、app.event(AppMentionEvent::class.java) でメンションに反応するようになります。そして、chatPostMessage で返信が送信されます。

object SlackApp {
  fun get(config: AppConfig? = null)): App {
    val app = App(config)
    app.event(AppMentionEvent::class.java) { payload, ctx ->
      val event = payload.event
      ctx.client().chatPostMessage {
        it.channel(event.channel)
        it.threadTs(event.ts)
        it.text("Hello")
      }
      ctx.ack()
    }
    return app
  }
}

ここまでは Slack Bot を Kotlin + Serverless Framework + AWS Lambda で作る の対応と同じです。

RequestHandler を実装する

一つのワークスペースだけに対応する場合は 下記のように SlackApiLambdaHandler を実装すれば問題なく動作しましたが、複数ユーザーに対応する場合はこれでは対応できません

class SlackEventHandler() : SlackApiLambdaHandler(SlackApp.get()) {
  override fun isWarmupRequest(awsReq: ApiGatewayRequest?): Boolean {
    return false
  }
}

https://xxxxxx/slack/events/{userId} というURLの パスパラメータ userId を受け取る必要があるが、 SlackApiLambdaHandler では そもそもパスパラメータを受け取る術がありません。

またもし受け取れたとしても、受け取れるタイミングは handleRequest のメソッドが呼ばれるタイミングになります。 SlackApiLambdaHandler では handleRequest が呼ばれる前の コンストラクタのタイミングで com.slack.api.bolt.App の初期化を終わらせる必要があります。 そういった理由からも、今回のケースで SlackApiLambdaHandler を使用することはできません。

そのため SlackApiLambdaHandler を参考にして、 handleRequest で パスパラメータ userId をもとに Slackの設定値を取得して config を作り、com.slack.api.bolt.Appをここで初期化する RequestHandlerを実装します。

下記がその実装をした SlackEventHandler です。

class SlackEventHandler :
  RequestHandler<ApiGatewayRequest?, ApiGatewayResponse?> {

  override fun handleRequest(input: ApiGatewayRequest?, context: Context?): ApiGatewayResponse? {
    if (input == null) {
      return ApiGatewayResponse.builder().statusCode(400).rawBody("Invalid Request").build()
    }
    return try {
      val app = getApp(input)
      app.start()

      val parser = SlackRequestParser(app.config())
      val req = toSlackRequest(input, parser)
      toApiGatewayResponse(app.run(req))
    } catch (e: NotFoundSlackException) {
      ApiGatewayResponse.builder().statusCode(404).rawBody("Not Found Slack Setting").build();
    } catch (e: NotFoundUserException) {
      ApiGatewayResponse.builder().statusCode(404).rawBody("Not Found User").build();
    } catch (t: Throwable) {
      ApiGatewayResponse.builder().statusCode(500).rawBody("Internal Server Error").build();
    }
  }

  private fun getApp(input: ApiGatewayRequest): App {
    val userId = input.pathParameters["userId"] ?: throw NotFoundUserException()

    ... // userId をもとに DyanamoDB から Slackの設定値を取得して appConfig を生成する処理

    return SlackApp.get(appConfig)
  }

  private fun toSlackRequest(awsReq: ApiGatewayRequest, parser: SlackRequestParser): Request<*> {
    ... // awsReq を Slack の Request に変換する処理
  }

  private fun toApiGatewayResponse(slackResponse: Response): ApiGatewayResponse {
    ... // Slack の Response を AWSGatewayResponse に変換する処理
  }
}

※ コードの全貌は 付録-SlackEventHandlerのコード にあります。

実装はこれで完了です。 Graldeプロジェクトをビルドして fat jar ファイルを生成しておきます。

Serverless Frameworkを使って deploy までやる

serverless.yml を記述する

serverless.yml を作っていきます。
内容は下記で、適宜書き換えてください。

service: multiuser-slack-bot <--- lambda の名前を定義

provider:
  name: aws
  runtime: java11
  stage: ${opt:stage, 'dev'}
  region: ap-northeast-1
  apiGateway:
    shouldStartNameWithService: true
  iamRoleStatements:
    - Effect: Allow
      Action:
        - lambda:InvokeFunction
        - lambda:InvokeAsync
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
        - dynamodb:Query
        - dynamodb:Scan
      Resource: "*"

  environment:
    SERVERLESS_STAGE: ${opt:stage, 'dev'}
    TABLE_NAME_SLACK: ${self:custom.slackTableName}

package:
  artifact: ./path/multiuser-slack-bolt.jar <--- jarのパスを設定

functions:
  api:
    handler: com.kgmyshin.slack.SlackEventHandler
    timeout: 30
    events:
      - http:
          path: slack/events/{userId} <--- userIdをパスパラメータとして定義
          method: post

resources:
  Resources:
    SlackTable: <--- テーブル定義はサンプル。自分のテーブル定義に合わせてください
      Type: "AWS::DynamoDB::Table"
      Properties:
        TableName: ${self:custom.slackTableName}
        AttributeDefinitions:
          - AttributeName: UserId
            AttributeType: S
        KeySchema:
          - AttributeName: UserId
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST

custom:
  slackTableName: slack-${opt:stage, 'dev'}

deployする

serverless.yml があるところで下記を実行します.

serverless deploy

デプロイが完了すると endpoint が表示されているはずなので、これをメモします。
流れちゃった人は、下記で再度 endpoint などを見ることができます。

serverless info

完成

これで https://xxxxxx/slack/events/{userId} にリクエストが来た場合、 userId に応じて Slack の設定値を DyanamoDB から引っ張ってきて適切に返答する Slack Bot の完成です.

例えば userId = f21f49ea-d2c4-4135-bec8-b0a05768463c の人は https://xxxxxx/slack/events/f21f49ea-d2c4-4135-bec8-b0a05768463cEvent Subscription Request URL に設定することで機能するようになります。

付録

SlackEventHandlerのコード

class SlackEventHandler :
  RequestHandler<ApiGatewayRequest?, ApiGatewayResponse?> {

  override fun handleRequest(input: ApiGatewayRequest?, context: Context?): ApiGatewayResponse? {
    if (input == null) {
      return ApiGatewayResponse.builder().statusCode(400).rawBody("Invalid Request").build()
    }
    return try {
      val app = getApp(input)
      app.start()

      val parser = SlackRequestParser(app.config())
      val req = toSlackRequest(input, parser)
      toApiGatewayResponse(app.run(req))
    } catch (e: NotFoundSlackException) {
      ApiGatewayResponse.builder().statusCode(404).rawBody("Not Found Slack Setting").build();
    } catch (e: NotFoundUserException) {
      ApiGatewayResponse.builder().statusCode(404).rawBody("Not Found User").build();
    } catch (t: Throwable) {
      ApiGatewayResponse.builder().statusCode(500).rawBody("Internal Server Error").build();
    }
  }

  private fun getApp(input: ApiGatewayRequest): App {
    val userId = input.pathParameters["userId"] ?: throw NotFoundUserException()

    val dynamoDb = AmazonDynamoDBClientBuilder.standard().withRegion(Regions.AP_NORTHEAST_1).build()

    val slackRecord = dynamoDb.query(
      QueryRequest(System.getenv("TABLE_NAME_SLACK"))
        .withKeyConditionExpression("UserId = :userId")
        .withExpressionAttributeValues(
          mapOf(
            ":userId" to AttributeValue().withS(userId)
          )
        )
        .withLimit(1)
    ).items.firstOrNull() ?: throw NotFoundSlackException()

    val botToken = slackRecord["BotToken"]?.s ?: throw NotFoundSlackException()
    val signingSecret = slackRecord["SigningSecret"]?.s ?: throw NotFoundSlackException()
    val appConfig = AppConfig().apply {
      this.singleTeamBotToken = botToken
      this.signingSecret = signingSecret
    }
    return SlackApp.get(appConfig)
  }

  private fun toSlackRequest(awsReq: ApiGatewayRequest, parser: SlackRequestParser): Request<*> {
    val context = awsReq.requestContext
    val body = if (awsReq.isBase64Encoded) {
      String(Base64.getDecoder().decode(awsReq.body))
    } else {
      awsReq.body
    }
    val rawReq = SlackRequestParser.HttpRequest.builder()
      .requestUri(awsReq.path)
      .queryString(toStringToStringListMap(awsReq.queryStringParameters))
      .headers(RequestHeaders(toStringToStringListMap(awsReq.headers)))
      .requestBody(body)
      .remoteAddress(context?.identity?.sourceIp)
      .build()
    return parser.parse(rawReq)
  }

  private fun toApiGatewayResponse(slackResponse: Response): ApiGatewayResponse {
    return ApiGatewayResponse.builder()
      .statusCode(slackResponse.statusCode)
      .headers(toStringToStringMap(slackResponse.headers))
      .rawBody(slackResponse.body)
      .build()
  }

  private fun toStringToStringMap(stringToStringListMap: Map<String, List<String>>): Map<String, String> {
    return stringToStringListMap.entries.fold(mutableMapOf()) { acc, entry ->
      if (entry.value.isNotEmpty()) {
        acc[entry.key] = entry.value[0]
      }
      acc
    }
  }

  private fun toStringToStringListMap(stringToStringListMap: Map<String, String>?): Map<String, List<String>> {
    return stringToStringListMap?.entries?.fold(mutableMapOf()) { acc, entry ->
      acc[entry.key] = listOf(entry.value)
      acc
    } ?: emptyMap()
  }
}

Discussion