複数ユーザーに対応したSlack Bot を Kotlin + Serverless Framework + AWS Lambda で作る
Slack Botを複数のワークスペースで使いたかったり、Slack連携できるようなサービスを開発している場合があります。
そういうケースにおいて、どのように実装すればいいのか解説していきます。
複数ユーザーに対応する必要ない場合のものについては、下記で説明しています。
流れとしては大きく変わらないため、可能ならば下記も一読してからこちらの記事を読んでください。
はじめに
前提
別途ダッシュボードなどが用意されており、DynamoDB に 各ユーザーの Bot Token
と Signing Secret
が保存されているとします
方針
エンドポイントを https://xxxxxx/slack/events/{userId}
というふうにして、 userId をもとに保存している Bot Token
や Signing Secret
を com.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.gradle
や build.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-b0a05768463c
を Event 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