Building a Slack AI Chatbot with Amazon Bedrock Claude and LangChain4j

Building a Slack AI Chatbot with Amazon Bedrock Claude and LangChain4j

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 in Amazon 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]

References