Overview
- In the era of Gen AI, one of the best ways to leverage Large Language Models (LLMs) is through the familiar platform of
Slack
. By creating an AI Chatbot as a Slack App, we can make it operate with a specified persona, much like interacting with a real person. This post introduces a method to create a Slack-based AI chatbot using Kotlin, Spring Boot, Amazon Bedrock Claude, and LangChain4j.
Steps
Request permission to use Amazon Bedrock Claude 3.5 Sonnet in your AWS Infra
Develop a chatbot program that sends AI responses to user questions in Slack
Create a Slack App to act as the Chatbot and obtain necessary Bot User OAuth Token
Required Slack OAuth Bot Token Scopes
app_mentions:read
: Allows the app to read messages where it is mentioned. This is limited to channels where the app has been added.channels:history
: Enables the app to view message history in public channels to which it has been added.groups:history
: Permits the app to view message history in private channels to which it has been invited.im:history
: Allows the app to view the history of direct messages with the app.mpim:history
: Enables the app to view the history of multi-person direct messages that include the app.chat:write
: Allows the app to send messages, but only in channels or conversations where it has been added.files:read
: Permits the app to read files shared in channels or conversations where it has access permissions.
Activating Amazon Bedrock Model Access: Claude 3.5 Sonnet
- To utilize the LLM, we'll activate
Claude 3.5 Sonnet
inAmazon Bedrock
. (Using the Amazon Bedrock Runtime API instead of the Anthropic API ensures data security even if sensitive company information is passed to the LLM.)
Amazon Bedrock Console
→ [Providers]
→ [Anthropic]
→ [Claude 3.5 Sonnet]
→ [Request model access]
→ Claude 3.5 Sonnet: [Available to request]
→ [Request model access]
# Edit model access
→ [Next]
# Review and submit
→ [Submit]
build.gradle.kts
- Create a Spring Boot-based project and add the following libraries:
val langChain4jVersion = "0.35.0"
dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("dev.langchain4j:langchain4j-core:$langChain4jVersion")
implementation("dev.langchain4j:langchain4j-bedrock:$langChain4jVersion")
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14")
}
Creating JsonConfig
- Create an
ObjectMapper
bean to convert user message JSON received from Slack to DTO and vice versa for AI responses:
@Configuration
class JsonConfig {
@Bean("objectMapper")
@Primary
fun objectMapper(): ObjectMapper {
return Jackson2ObjectMapperBuilder
.json()
.serializationInclusion(JsonInclude.Include.ALWAYS)
.failOnEmptyBeans(false)
.failOnUnknownProperties(false)
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.modulesToInstall(kotlinModule(), JavaTimeModule())
.build()
}
}
Creating AmazonBedrockConfig
- Create a LangChain4j's
ChatLanguageModel
interface to use Claude 3.5 Sonnet from Amazon Bedrock:
@Configuration
class AmazonBedrockConfig {
@Bean("amazonBedrockClaude35SonnetChatLanguageModel")
fun amazonBedrockClaude35SonnetChatLanguageModel(): ChatLanguageModel {
return BedrockAnthropicMessageChatModel.builder()
// Currently only supported in US East (N. Virginia) region
.region(Region.US_EAST_1)
.model("anthropic.claude-3-5-sonnet-20240620-v1:0")
// Adjust based on chatbot personality
.temperature(0.3)
.topP(0.3f)
.maxTokens(200000)
.build()
}
}
Creating OkHttpConfig
- Create an
OkHttpClient
bean to send AI responses to Slack:
@Configuration
class OkHttpConfig {
@Bean("okHttpClient")
fun okHttpClient(): OkHttpClient {
return OkHttpClient()
.newBuilder().apply {
dispatcher(Dispatcher(Executors.newVirtualThreadPerTaskExecutor()))
connectionSpecs(
listOf(
ConnectionSpec.CLEARTEXT,
ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.allEnabledTlsVersions()
.allEnabledCipherSuites()
.build()
)
)
connectTimeout(10, TimeUnit.SECONDS)
writeTimeout(10, TimeUnit.SECONDS)
readTimeout(10, TimeUnit.SECONDS)
}.build()
}
}
Creating SlackService
- Develop a SlackService to send LLM responses back to users:
@Service
class SlackService(
private val okHttpClient: OkHttpClient,
private val objectMapper: ObjectMapper
) {
@Async(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
fun sendMessage(channel: String?, threadId: String? = null, text: String?, botUserOAuthToken: String?) {
if (channel.isNullOrBlank()) return
if (text.isNullOrBlank()) return
if (botUserOAuthToken.isNullOrBlank()) return
// Output in markdown format
val requestBodyMap = mutableMapOf(
"channel" to channel,
"blocks" to listOf(
mapOf(
"type" to "section",
"text" to mapOf(
"type" to "mrkdwn",
"text" to text
)
)
),
"parse" to "full"
)
if (!threadId.isNullOrBlank()) {
requestBodyMap["thread_ts"] = threadId
}
val requestBody = objectMapper.writeValueAsString(requestBodyMap)
okHttpClient.newCall(
Request.Builder()
.url("https://slack.com/api/chat.postMessage")
.addHeader(
"Authorization",
"Bearer $botUserOAuthToken"
)
.post(requestBody.toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
).execute().use { response ->
if (!response.isSuccessful) {
// Exception handling and logging
}
}
}
}
Creating Slack Event Handler Service
- Create a service to send LLM responses to user questions that mention the chatbot:
@Service
class FooChatbotService(
private val objectMapper: ObjectMapper,
@Qualifier("amazonBedrockClaude35SonnetChatLanguageModel") private val amazonBedrockClaude35SonnetChatLanguageModel: ChatLanguageModel,
private val slackService: SlackService
) {
@Async(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
fun processSlackEvent(requestBody: String?) {
if (requestBody.isNullOrBlank()) return
val request: SlackEventCallback = try {
objectMapper.readValue(requestBody, SlackEventCallback::class.java)
} catch(ex: Exception) {
// Exception handling and logging
return
}
// Ignore questions that don`t mention the chatbot
if (!request.event.text.contains("<@${your-slack-bot-user-oauth-token}>")) {
return
}
// Request and obtain LLM response
val aiMessage: Response<AiMessage> = chatLanguageModel.generate(
SystemMessage("{your-system-prompt}"),
UserMessage(request.event.text)
)
// Send LLM response to the original Slack question thread
slackService.sendMessage(
channel = request.event.channel,
thread = request.event.threadTs ?: request.event.ts,
text = aiMessage.content().text(),
token = "{your-slack-bot-user-oauth-token}"
)
}
}
data class SlackEventCallback(
val token: String,
@JsonProperty("team_id") val teamId: String,
@JsonProperty("context_team_id") val contextTeamId: String,
@JsonProperty("context_enterprise_id") val contextEnterpriseId: String?,
@JsonProperty("api_app_id") val apiAppId: String,
val event: SlackEvent,
val type: String,
@JsonProperty("event_id") val eventId: String,
@JsonProperty("event_time") val eventTime: Long,
val authorizations: List<SlackAuthorization>,
@JsonProperty("is_ext_shared_channel") val isExtSharedChannel: Boolean,
@JsonProperty("event_context") val eventContext: String
)
data class SlackEvent(
val user: String,
val type: String,
val ts: String,
@JsonProperty("client_msg_id") val clientMsgId: String?,
val text: String,
val team: String?,
@JsonProperty("thread_ts") val threadTs: String?,
val blocks: List<SlackBlock>?,
val channel: String,
@JsonProperty("event_ts") val eventTs: String,
@JsonProperty("channel_type") val channelType: String,
val files: List<SlackFile>?,
val upload: Boolean?,
@JsonProperty("display_as_bot") val displayAsBot: Boolean?,
val subtype: String?
)
data class SlackFile(
val id: String,
val created: Long,
val timestamp: Long,
val name: String,
val title: String,
val mimetype: String,
val filetype: String,
@JsonProperty("pretty_type") val prettyType: String,
val user: String,
@JsonProperty("user_team") val userTeam: String,
val editable: Boolean,
val size: Int,
val mode: String,
@JsonProperty("is_external") val isExternal: Boolean,
@JsonProperty("external_type") val externalType: String,
@JsonProperty("is_public") val isPublic: Boolean,
@JsonProperty("public_url_shared") val publicUrlShared: Boolean,
@JsonProperty("display_as_bot") val displayAsBot: Boolean,
val username: String,
@JsonProperty("url_private") val urlPrivate: String,
@JsonProperty("url_private_download") val urlPrivateDownload: String,
@JsonProperty("media_display_type") val mediaDisplayType: String,
@JsonProperty("thumb_64") val thumb64: String?,
@JsonProperty("thumb_80") val thumb80: String?,
@JsonProperty("thumb_360") val thumb360: String?,
@JsonProperty("thumb_360_w") val thumb360W: Int,
@JsonProperty("thumb_360_h") val thumb360H: Int,
@JsonProperty("thumb_480") val thumb480: String?,
@JsonProperty("thumb_480_w") val thumb480W: Int,
@JsonProperty("thumb_480_h") val thumb480H: Int,
@JsonProperty("thumb_160") val thumb160: String?,
@JsonProperty("original_w") val originalW: Int,
@JsonProperty("original_h") val originalH: Int,
@JsonProperty("thumb_tiny") val thumbTiny: String?,
val permalink: String,
@JsonProperty("permalink_public") val permalinkPublic: String,
@JsonProperty("has_rich_preview") val hasRichPreview: Boolean,
@JsonProperty("file_access") val fileAccess: String
)
data class SlackBlock(
val type: String?,
@JsonProperty("block_id") val blockId: String?,
val elements: List<SlackElement>?
)
data class SlackElement(
val type: String?,
val elements: List<SlackElementContent?>?
)
data class SlackElementContent(
val type: String,
@JsonProperty("user_id") val userId: String? = null,
val text: String? = null
)
data class SlackAuthorization(
@JsonProperty("enterprise_id") val enterpriseId: String?,
@JsonProperty("team_id") val teamId: String,
@JsonProperty("user_id") val userId: String,
@JsonProperty("is_bot") val isBot: Boolean,
@JsonProperty("is_enterprise_install") val isEnterpriseInstall: Boolean
)
Creating Slack Event Handler Controller
- Create a controller to receive messages sent to the chatbot in Slack and pass them to the previously created service:
@RestController
class FooChatbotController(
private val objectMapper: ObjectMapper,
private val fooChatbotService: FooChatbotService
) {
@PostMapping("/v1/slack/events/foo-chatbot")
fun onProcessSlackEvent(@RequestBody requestBody: String, request: HttpServletRequest): ResponseEntity<String> {
val request = try {
objectMapper.readValue(requestBody, Map::class.java)
} catch (_: Exception) {
return ResponseEntity.noContent().build()
}
// Used for Request URL verification in Event Subscriptions menu
request["type"]?.let { type ->
if (type == "url_verification") {
request["challenge"]?.let { challenge ->
return ResponseEntity.ok().body(challenge.toString())
}
}
}
// Process all other message events
fooChatbotService.processSlackEvent(requestBody)
return ResponseEntity.ok("OK")
}
}
Creating a Slack App
- The chatbot application is now complete. The final step is to create a dedicated App in Slack for the chatbot. Don't forget to securely store and inject the generated Bot User OAuth Token into your application.
Visit https://api.slack.com/apps
# Your Apps
→ [Create an App]
# Create an app
→ [From an app manifest]
# Pick a workspace to develop your app
→ Select: {your-workspace}
→ [Next]
# Enter app manifest below
→ JSON: (Paste the following content)
{
"display_information": {
"name": "{your-chatbot-name}"
},
"features": {
"app_home": {
"home_tab_enabled": false,
"messages_tab_enabled": true,
"messages_tab_read_only_enabled": false
},
"bot_user": {
"display_name": "{your-chatbot-name}",
"always_online": true
}
},
"oauth_config": {
"scopes": {
"bot": [
"app_mentions:read",
"channels:history",
"groups:history",
"im:history",
"mpim:history",
"chat:write",
"files:read"
]
}
},
"settings": {
"event_subscriptions": {
"request_url": "https://{your-domain}/v1/slack/events/foo-chatbot",
"bot_events": [
"app_mention",
"message.channels",
"message.groups",
"message.im",
"message.mpim"
]
},
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"token_rotation_enabled": false
}
}
→ [Next]
# Review summary & create your app
→ [Create]
# Basic Information
→ [Install to Workspace]
→ [Allow]
# OAuth & Permissions
→ Copy the Bot User OAuth Token content
{your-slack-bot-user-oauth-token}
# Event Subscriptions
→ Request URL: [Retry]
→ [Save Changes]