JetBrains Space Help

(Kotlin) How to Create a Chatbot

What's a chatbot? It's a Space application that communicates with a Space user in its own Chats channel. A minimum viable bot must:

  • Respond with a list of available commands when a user types / (slash) in the channel.

  • Provide at least one command: After a user sends this command to the channel, the bot must do something and then respond with a message.

What will we do

Your first chatbot, of course! Without further ado, please welcome – the 'Remind me' bot!

Our bot will send a reminder to a user after a given amount of time. For example, if a user sends the remind 60 take a nap command to the bot, after 60 seconds, the bot will respond with the take a nap message. Also, our bot will have a command that provides help when requested.

This tutorial will guide you through the entire chatbot creation process, but you can always download the resulting source code.

Chatbot-creator starter kit

What will we need along our journey?

Intellij IDEA

JetBrains IntelliJ IDEA

We'll write our bot in Kotlin. So, you can use any IDE of your choice, but this tutorial implies that you use IntelliJ IDEA.

Ktor framework

Ktor framework

This is a framework that lets you easily create all types of connected applications, e.g., servers, clients, mobile, and browser-based apps. We'll get it as one of Gradle dependencies, so, no additional actions are required from your side. Of course, outside of this tutorial, you can create a Space bot using any web framework you like, e.g., Node.js, ASP.NET, and so on.

Space SDK

Space SDK

As you might know from the Develop Applications topic, any application must communicate with Space using Space HTTP API. To ease the life of Space app developers, we provide Space SDK for Kotlin and .NET. The SDK contains the HTTP API client that lets you easily authenticate in and communicate with Space by using multiple high-level classes. As well as the Ktor framework, we'll get the SDK as a Gradle dependency.

ngrok

A tunnelling service

Such a service exposes local servers to the public internet. It will let us run our chatbot locally and access it from Space via a public URL (we'll specify it as the chatbot's endpoint). For example, you can use ngrok, PageKyte, or another tunnelling service for this purpose. To start working with the tunneling service, you should download a service client: ngrok client, PageKyte client. For our purposes, the free plan for ngrok or PageKyte is enough.

Step 1. Create a Ktor project

  1. Open Intellij IDEA.

  2. Start creating a new project with File | New | Project.

  3. In the list of templates, select Ktor.

  4. Specify a project Name and a Website name, select Gradle Kotlin in Build System, and clear the Add sample code checkbox.

    New Ktor project
  5. Click Next. We're not going to install any Ktor plugins, so, on the next page just click Create.

  6. That's it! Now, we have a blank Ktor project.

    New Ktor project

Step 2. Get Space SDK and other dependencies

Our chatbot requires the following libraries:

  • Space SDK for JVM

  • FasterXML/jackson: to work with JSON payloads.

  • Ktor HTTP client: a generic Ktor app doesn't reference libraries for HTTP client. We'll need a client to send requests to Space. We'll use the CIO Ktor HTTP client but you can use any other web engine for the HTTP client.

  1. Open build.gradle.kts and add:

    • To the repositories section:

      maven("https://maven.pkg.jetbrains.space/public/p/space/maven")
    • To the dependencies section, add dependencies to the required libraries:

      implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.13.3' implementation "org.jetbrains:space-sdk-jvm:$space_sdk_version" implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-cio:$ktor_version"
      implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jackson_version") implementation("org.jetbrains:space-sdk-jvm:$space_sdk_version") implementation("io.ktor:ktor-client-core:$ktor_version") implementation("io.ktor:ktor-client-cio:$ktor_version")

    In gradle.properties, specify the required package versions:

    ktor_version=2.0.3 jackson_version=2.13.3 # We use SDK v.106390, but when you read this tutorial, a newer version may be available. # To find out which SDK version is the latest available: open API Playground, # in the "Code" section on the right, choose "Kotlin SDK", and click "Set up dependency..." space_sdk_version=106390-beta
  2. We're not going to write any tests for this application, so clean up all testImplementation dependencies from the dependencies section.

    Your build.gradle.kts should look like this:

    val ktor_version: String by project val kotlin_version: String by project val logback_version: String by project val space_sdk_version: String by project val jackson_version: String by project plugins { application kotlin("jvm") version "1.7.10" } group = "com.example" version = "0.0.1" application { mainClass.set("com.example.ApplicationKt") val isDevelopment: Boolean = project.ext.has("development") applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") } repositories { mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/space/maven") } dependencies { implementation("io.ktor:ktor-server-core-jvm:$ktor_version") implementation("io.ktor:ktor-server-netty-jvm:$ktor_version") implementation("ch.qos.logback:logback-classic:$logback_version") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jackson_version") implementation("org.jetbrains:space-sdk-jvm:$space_sdk_version") implementation("io.ktor:ktor-client-core:$ktor_version") implementation("io.ktor:ktor-client-cio:$ktor_version") }

    gradle.properties should look like this:

    ktor_version=2.0.3 kotlin_version=1.7.10 logback_version=1.2.3 jackson_version=2.13.3 kotlin.code.style=official space_sdk_version=106390-beta

  3. In the Gradle window, click Reload All Gradle Projects to make Gradle download the required dependencies.

    Update dependencies
  4. Done! Now, we have Space SDK in our project.

Step 3. Run the tunneling service

Before we register our chatbot in Space, we have to get a publicly available URL for it. As your development environment is probably located behind NAT, the easiest way to get the URL is to use a tunneling service. In our case, we'll use ngrok.

  1. Download and unzip the ngrok client.

  2. In terminal (on macOS or Linux) or in the command line (on Windows), open the ngrok directory.

  3. By default, our Ktor project is configured to run the HTTP server on the port 8080 (you can check this in the resources/application.conf file). Run tunnelling for this port:

    ./ngrok http 8080
  4. The ngrok service will start. It will look similar to:

    Session Status online Account user@example.com (Plan: Free) Version 3.0.6 Region United States (us) Latency - Web Interface http://127.0.0.1:4040 Forwarding https://98af-94-158-242-146.ngrok.io -> http://localhost:8080 Connections ttl opn rt1 rt5 p50 p90 0 0 0.00 0.00 0.00 0.00

    Here we are interested in the Forwarding line – it contains the public URL. ngrok redirects requests from this URL to our localhost using its tunnelling service. In the example above, the address is https://98af-94-158-242-146.ngrok.io but in your case it will be something else as ngrok generates these random URLs dynamically.

  5. Great job! Now, we have a running tunneling service and the public URL of our future chatbot.

Step 4. Register the chatbot in Space

In order Space and chatbot could communicate with each other, we must register the bot in Space.

When developing an application, you must decide on two important things:

  • Application distribution:

    • Single-org applications are intended only for a single Space organization. A Space user registers and configures a single-org application manually in the Space UI.

    • Multi-org applications are intended for multiple Space organizations. A multi-org application registers and configures itself in a particular Space instance using API calls.

    Since we are just practicing, there is no point in being distracted by the complexities of configuring a multi-org application. Instead, we will register and configure our application using the Space UI. So, we are going to create a single-org application.

  • Authorization subject: decide how your application should act in Space – on behalf of itself, on behalf of a particular Space user, or both. This determines which authorization flows the application will use.

    In our case, a chatbot will send notifications in its own chat channel on befalf of itself. As an OAuth 2.0 flow, we will use the Client Credentials flow. It lets the application authorize in Space using a client ID and a client secret.

To summarize, our chatbot is a single-org application that uses the Client Credentials authorization flow. Now, let's register our bot in Space.

  1. Open your Space instance.

  2. On the navigation bar, click Extensions Extensions and choose Installed to organization.

  3. Click New application.

  4. Give the application a unique name, say, remind-me-bot and click Create.

  5. Open the Authorization tab. We will not change anything on this tab – as our bot is simple and doesn't get any data from Space.

    We're on this tab just for you to note how important it is – if your app is supposed to access various Space modules, you should provide it corresponding permissions. Learn more about requesting permissions.

    Requested permissions
  6. Open the Authentication tab. Note that the Client Credentials Flow is enabled for all applications by default. We need to get the application's Client ID and Client secret. Our chatbot will use them to get a Space access token.

    Authentications
  7. When a user types anything in the chatbot channel, Space sends the user input to the application. So, our next step is to specify the URL of our application endpoint and choose how we will verify requests from Space.

    Open the Endpoint tab.

    In the Endpoint URI, specify the public URL generated by the tunnelling service for our bot. Let's make this endpoint less generic and add a postfix to the URL, for example api/space. So, the final endpoint would be https://{random_string_from_ngrok}.ngrok.io/api/space

    By default, Space recommends using the Public key verification method. Let's leave the default and click Save.

    Endpoint
  8. Great job! Now, our bot is registered in Space, we have all required authentication data, and we're ready to start developing our bot.

Step 5. Create a Space client

All preparation steps are behind, let's do some coding! First, we should start with creating a Space client that will let us make requests to our Space instance.

  1. Add the Client.kt file to the project.

  2. Add the code to the Client.kt file:

    package org.remindme import space.jetbrains.api.runtime.SpaceAppInstance import space.jetbrains.api.runtime.SpaceAuth import space.jetbrains.api.runtime.SpaceClient import space.jetbrains.api.runtime.ktorClientForSpace import space.jetbrains.api.runtime.resources.chats import space.jetbrains.api.runtime.types.ChannelIdentifier import space.jetbrains.api.runtime.types.ChatMessage import space.jetbrains.api.runtime.types.ProfileIdentifier // describes connection to a Space instance val spaceAppInstance = SpaceAppInstance( // Copy-paste the client-id, and the client-secret // your app got from Space. clientId = "client-id-assigned-to-app", clientSecret = "client-secret-assigned-to-app", // URL of your Space instance spaceServerUrl = "https://mycompany.jetbrains.space" ) private val spaceHttpClient = ktorClientForSpace() // The Space client is used to call Space API methods. // The application uses the Client Credentials OAuth flow (see [SpaceAuth.ClientCredentials]) // to authorize on behalf of itself. val spaceClient = SpaceClient(ktorClient = spaceHttpClient, appInstance = spaceAppInstance, auth = SpaceAuth.ClientCredentials()) // Get user by ID and send 'message' to the user. // 'spaceClient' gives you access to any Space endpoint. suspend fun sendMessage(userId: String, message: ChatMessage) { spaceClient.chats.messages.sendMessage( channel = ChannelIdentifier.Profile(ProfileIdentifier.Id(userId)), content = message ) }
    Notes:

    • sendMessage(userId: String, message: ChatMessage) uses the client to send messages to Space. Let's expand on that:

      • userId is the ID of the user who sent a message to our application and whom we want to reply.

      • ChatMessage is the API class that describes a Chat message. The thing is, messages are not just text. They can include complex formatting and even UI elements, like buttons. To simplify creating such messages, the API client provides a special DSL – Message Constructor. We'll take a look at it in the next steps.

      • spaceClient.chats.messages.sendMessage() – look at how we refer to the Chats subsystem. The coolest thing about the SpaceHttpClient class is that it lets you access any Space module. Thus, if you want to get a member profile, you can make a call to Team Directory:

        spaceClient.teamDirectory.profiles.getProfile(ProfileIdentifier.Username("John.Doe"))

        To see the list of modules that you can access via the client, open the API Playground. The top-level titles here are the modules that can be accessed:

        API Playground

        You can also use the code completion in your IDE – just type spaceClient. and explore the suggestions.

  3. Done! Now, we have a client, so we can move on and create the first command for our chatbot.

Step 6. Create your first command

Let's start with something simple – the help command that shows hints on how to use our chatbot.

  1. Create the CommandHelp.kt file and add the following code:

    package org.remindme import space.jetbrains.api.runtime.helpers.message import space.jetbrains.api.runtime.types.ApiIcon import space.jetbrains.api.runtime.types.ChatMessage import space.jetbrains.api.runtime.types.MessageOutline import space.jetbrains.api.runtime.types.MessageStyle // command for showing chatbot help suspend fun runHelpCommand(payload: MessagePayload) { // get user ID from the payload and send them a help message sendMessage(payload.userId, helpMessage()) } // build the help message using the special DSL fun helpMessage(): ChatMessage { return message { section { text("Soon the help will be shown here!") } } }
  2. Nice! Now, we have a command that a user can actually try in action.

Step 7. Define an endpoint

The next step is to create the bot's endpoint in our code. After this step, we'll finally be able to send a command to our bot.

It is not a Ktor tutorial, so, we will not dig into Ktor specifics. By the way, if you're not familiar with Ktor, it's a good opportunity to get acquainted with this wonderful framework: just note how concise the Ktor syntax is.

  1. Our chatbot is not only a client application, but it is also a server – it should listen for and handle the requests that come from Space. For this purpose, Ktor provides the routing feature. Let's use this feature to handle POST requests on the /api/space endpoint.

    Create the Routes.kt file and add the following code:

    package org.remindme import com.fasterxml.jackson.databind.ObjectMapper import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.coroutines.launch import space.jetbrains.api.ExperimentalSpaceSdkApi import space.jetbrains.api.runtime.helpers.command import space.jetbrains.api.runtime.helpers.readPayload import space.jetbrains.api.runtime.helpers.verifyWithPublicKey import space.jetbrains.api.runtime.types.ListCommandsPayload import space.jetbrains.api.runtime.types.MessagePayload fun Application.configureRouting() { routing { post("api/space") { // read request body val body = call.receiveText() // read headers required for Space verification val signature = call.request.header("X-Space-Public-Key-Signature") val timestamp = call.request.header("X-Space-Timestamp")?.toLongOrNull() // verifyWithPublicKey gets a key from Space, uses it to generate message hash // and compares the generated hash to the hash in a message if (signature.isNullOrBlank() || timestamp == null || !spaceClient.verifyWithPublicKey( body, timestamp, signature ) ) { call.respond(HttpStatusCode.Unauthorized) return@post } // analyze the message payload // MessagePayload = user sends a command // ListCommandsPayload = user types a slash or a char when (val payload = readPayload(body)) { is MessagePayload -> { runHelpCommand(context) call.respond(HttpStatusCode.OK, "") } is ListCommandsPayload -> { } } } } }

    What is going on here:

    • Application.configureRouting() configures the Ktor routing feature.

    • spaceClient.verifyWithPublicKey() verifies the Space instance from which the client receives a request. It uses a public key from Space to calculate message hash and compare it to the hash sent in a message header. You can find more info about how it works here. In this tutorial, we process and verify the requests by ourselves. Alternatively, you can use the Space.processPayload() helper method for this purpose. In this case, you don't need to implement request verification.

    • readPayload(body: String) is the SDK helper function that receives JSON data from Space and deserializes it into ApplicationPayload. For example, in our case, the raw data from Space could look like follows:

      { "className": "MessagePayload", "message": { "className": "MessageContext", "messageId": "JxT000JxT", "channelId": "31m0WE41iCBP", "body": { "className": "ChatMessage.Text", "text": "help" }, "createdTime": "2020-11-08T21:34:24.919Z" }, "accessToken": "", "verificationToken": "85e23ff", "userId": "1eAeu31CZA" }
    • The most interesting part is analyzing the type of payload. There are several payload types in the Space SDK, but, in this tutorial, we will focus only on two of them:

      • MessagePayload: The standard payload that contains the command and command arguments. Actually, the bot receives MessagePayload each time a user sends a message.

      • ListCommandsPayload: The bot receives this payload when a user hits the / slash button. The bot must respond with a list of available commands.

    • On receiving MessagePayload, it's important to respond with some HTTP code (200 OK in our case). Otherwise, the user will get the 500 Internal Server Error.

  2. Make sure the main() application function in Application.kt starts the server with enabled routing:

    fun main() { embeddedServer(Netty, port = 8080) { configureRouting() }.start(wait = true) }
  3. Done! Now, we have a working command, and we're ready to launch our bot for the first time!

Step 8. Run the bot

  1. Open the Application.kt file.

  2. Start the application by clicking Run in the gutter next to the main function in Application.kt:

    Run Ktor server
  3. Open your Space instance and find the bot: press Ctrl+K and type its name.

    Find the bot
  4. If you look into the code, you can see that we don't anyhow analyze the commands sent by the user. On any request, we respond with the helpMessage. So, to test our bot, type anything in the chat. You should receive the help message:

    Send command to bot
  5. It's working! Now, let's add the rest of the bot features.

Step 9. Add support for slash commands

Let's make our bot fully functional:

  • In order to be considered a chatbot, an application must be able to respond with a list of available commands when a user hits / slash in the chat. In this case, the bot receives the ListCommandsPayload type of payload.

  • As our bot is called the remind-me-bot, it needs some remind command that will start the timer and send the notification to the user once the timer is over.

  1. Create the Commands.kt file:

    package org.remindme import space.jetbrains.api.runtime.types.* class ApplicationCommand( val name: String, val info: String, val run: suspend (payload: MessagePayload) -> Unit ) { /** * [CommandDetail] is returned to Space with info about the command. * List of commands is shown to the user. */ fun toSpaceCommand() = CommandDetail(name, info) } // list of available commands val supportedCommands = listOf( ApplicationCommand( "help", "Show this help", ) { payload -> runHelpCommand(payload) }, ApplicationCommand( "remind", "Remind me about something in N seconds, e.g., " + "to remind about \"the thing\" in 10 seconds, send 'remind 10 the thing' ", ) { payload -> runRemindCommand(payload) } ) /** * Response to [ListCommandsPayload]. * Space will display the returned commands as commands supported by your app. */ fun getSupportedCommands() = Commands( supportedCommands.map { it.toSpaceCommand() } )

    Here:

    • ApplicationCommand class describes the chatbot command: After a user sends the command named name to the chatbot, the bot will run the command's run function.

      Note that the command must implement the toSpaceCommand function that returns CommandDetail– command name and description.

    • When a bot receives ListCommandsPayload, the getSupportedCommands() function will return a map of CommandDetail.

  2. Create the CommandRemind.kt file:

    package org.remindme import kotlinx.coroutines.delay import space.jetbrains.api.runtime.helpers.commandArguments import space.jetbrains.api.runtime.helpers.message import space.jetbrains.api.runtime.types.* suspend fun runRemindCommand(payload: MessagePayload) { val remindMeArgs = getArgs(payload) ?: run { sendMessage(payload.userId, helpMessage()) return } remindAfterDelay(payload.userId, remindMeArgs) } private suspend fun remindAfterDelay(userId: String, remindMeArgs: RemindMeArgs) { sendMessage(userId, acceptRemindMessage(remindMeArgs)) delay(remindMeArgs.delayMs) sendMessage(userId, remindMessage(remindMeArgs)) } private fun acceptRemindMessage(remindMeArgs: RemindMeArgs): ChatMessage { return message { outline( MessageOutline( icon = ApiIcon("checkbox-checked"), text = "Reminder accepted" ) ) section { text("I will remind you in ${remindMeArgs.delayMs / 1000} seconds about \"${remindMeArgs.reminderText}\"") } } } private fun remindMessage(remindMeArgs: RemindMeArgs): ChatMessage { return message { outline( MessageOutline( icon = ApiIcon("clock"), text = "Reminder" ) ) section { text(remindMeArgs.reminderText) text( size = MessageTextSize.SMALL, content = "${remindMeArgs.delayMs / 1000} seconds have passed" ) } } } private fun getArgs(payload: MessagePayload): RemindMeArgs? { val args = payload.commandArguments() ?: return null val delayMs = args.substringBefore(" ").toLongOrNull()?.times(1000) ?: return null val reminderText = args.substringAfter(" ").trimStart().takeIf { it.isNotEmpty() } ?: return null return RemindMeArgs(delayMs, reminderText) } private class RemindMeArgs( val delayMs: Long, val reminderText: String, )

    Here:

    • runRemindCommand runs the timer for the specified amount of time. getArgs() gets the arguments of the remind command (time and text). So, when a user types remind 10 some text, the arguments are 10, some text.

    • remindAfterDelay() sends the acceptRemindMessage back to the user and once the timer is over, sends remindMessage.

    • acceptRemindMessage and remindMessage use the message builder DSL to create a ChatMessage. MessageOutline is a small block that precedes the main message body. The ApiIcon returns an icon image that will be shown in front of the message.

  3. Edit the CommandHelp.kt file:

    package org.remindme import space.jetbrains.api.runtime.helpers.message import space.jetbrains.api.runtime.types.ApiIcon import space.jetbrains.api.runtime.types.ChatMessage import space.jetbrains.api.runtime.types.MessageOutline import space.jetbrains.api.runtime.types.MessageStyle suspend fun runHelpCommand(payload: MessagePayload) { sendMessage(payload.userId, helpMessage()) } fun helpMessage(): ChatMessage { return message { MessageOutline( icon = ApiIcon("checkbox-checked"), text = "Remind me bot help" ) section { text("List of available commands", MessageStyle.PRIMARY) fields { supportedCommands.forEach { field(it.name, it.info) } } } } }

    Here we've updated the helpMessage so that it now returns the list of supportedCommands.

  4. Edit the Routes.kt file:

    package org.remindme import com.fasterxml.jackson.databind.ObjectMapper import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.coroutines.launch import space.jetbrains.api.runtime.helpers.command import space.jetbrains.api.runtime.helpers.readPayload import space.jetbrains.api.runtime.helpers.verifyWithPublicKey import space.jetbrains.api.runtime.types.ListCommandsPayload import space.jetbrains.api.runtime.types.MessagePayload fun Routing.api() { post("api/space") { // read request body val body = call.receiveText() // verify if the request comes from a trusted Space instance val signature = call.request.header("X-Space-Public-Key-Signature") val timestamp = call.request.header("X-Space-Timestamp")?.toLongOrNull() // verifyWithPublicKey gets a key from Space, uses it to generate message hash // and compares the generated hash to the hash in a message if (signature.isNullOrBlank() || timestamp == null || !spaceClient.verifyWithPublicKey(body, timestamp, signature) ) { call.respond(HttpStatusCode.Unauthorized) return@post } when (val payload = readPayload(body)) { is ListCommandsPayload -> { // Space requests the list of supported commands call.respondText( // JSON serializer ObjectMapper().writeValueAsString(getSupportedCommands()), ContentType.Application.Json ) } is MessagePayload -> { // user sent a message to the application val commandName = payload.command() val command = supportedCommands.find { it.name == commandName } if (command == null) { runHelpCommand(payload) } else { launch { command.run(payload) } } call.respond(HttpStatusCode.OK, "") } } } }

    What we changed here:

    • When the bot receives ListCommandsPayload (a user types / slash), we now respond with a list of commands. Note that we use ObjectMapper() to convert the list to JSON.

    • We analyze MessagePayload trying to find the requested command in the list of available commands.

  5. Let's run the bot one more time and try all bot features:

    • Typing slash:

      Slash command
    • The help command:

      Slash commands
    • The remind command:

      Remind command

Great job! We have finished creating our simple bot. In the next tutorial we will learn how to add UI elements to chatbot messages.

Last modified: 25 November 2022