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!

All that our bot will be able to do is remind a user after a given amount of time. As simple as that: For example, a user sends the remind 60 command to the bot, and after 60 seconds, the bot sends a message with a reminder back. Also, our bot will have one more command that provides help.

Simple Space chatbot

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, of course, 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 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 Groovy 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 Finish.

  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 and add:

    • To the repositories section:

      maven { url "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.0" implementation "org.jetbrains:space-sdk-jvm:81262-beta" implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-cio:$ktor_version"

    Your build.gradle should look like follows:

    plugins { id 'application' id 'org.jetbrains.kotlin.jvm' version '1.5.31' } group "com" version "0.0.1" mainClassName = "com.ApplicationKt" repositories { mavenCentral() maven { url = "https://maven.pkg.jetbrains.space/public/p/space/maven" } } dependencies { implementation "io.ktor:ktor-server-core:$ktor_version" implementation "io.ktor:ktor-server-netty:$ktor_version" implementation "ch.qos.logback:logback-classic:$logback_version" testImplementation "io.ktor:ktor-server-tests:$ktor_version" testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version" implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.13.0' // we use version 81262, but when you read this tutorial // a newer version may be already available implementation "org.jetbrains:space-sdk-jvm:81262-beta" implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-cio:$ktor_version" }

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

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

Step 3. Run the tunnelling service

Before we register our chatbot in Space, we have to get its publicly available URL. As your development environment is probably located behind NAT, the easiest way to get the URL is to use a tunnelling 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:

    ngrok by @inconshreveable Session Status online Account john.doe@example.com (Plan: Free) Version 2.3.35 Region United States (us) Web Interface http://127.0.0.1:4040 Forwarding http://5a8dc7594bb0.ngrok.io -> http://localh Forwarding https://5a8dc7594bb0.ngrok.io -> http://local 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://5a8dc7594bb0.ngrok.io but in your case it'll be something else as ngrok generates these random URLs dynamically.

  5. Save the public URL, e.g. by copying it to the clipboard.

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

Step 4. Register the chatbot in Space

Before we can start developing, we must get Space authentication credentials for our bot. Moreover, we also need a special verification token that will allow the bot to verify our Space instance. To get all of these, we must register our bot in Space.

  1. Open your Space instance.

  2. On the navigation bar, click Extensions Administration and choose Applications.

  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. To check what permissions are required for a certain HTTP API call, go to API Playground.

    Requested permissions
  6. Open the Authentication tab. Our chatbot will authenticate on behalf of itself using the Client Credentials Flow: select the Client Credentials Flow checkbox and click Save. Also, we need to save application's Client ID and Client secret. Our chatbot will use them to get a Space access token via the client credentials flow.

    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 ngrok for our bot. Let's make this endpoint less generic and add an additional path to the URL, for example api/myapp. So, the final endpoint would be https://{random_string_from_ngrok}.ngrok.io/api/myapp

    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 provide communication with our Space instance.

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

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

    package com.remindme import io.ktor.client.* import space.jetbrains.api.runtime.SpaceHttpClient import space.jetbrains.api.runtime.helpers.verifyWithPublicKey import space.jetbrains.api.runtime.resources.chats import space.jetbrains.api.runtime.types.* import space.jetbrains.api.runtime.withServiceAccountTokenSource // url of your Space instance private const val url = "https://mycompany.jetbrains.space" // copy-paste client-id, and client-secret // your app got from Space private const val clientId = "" private const val clientSecret = "" // client for communication with Space // it uses the Client Credentials auth flow val spaceClient by lazy { SpaceHttpClient(HttpClient()) .withServiceAccountTokenSource( clientId = clientId, clientSecret = clientSecret, serverUrl = url ) } // verification of Space instance // gets a key from Space, uses it to generate message hash // and compares the generated hash to the hash in a message suspend fun verifyRequestWithPublicKey( body: String, signature: String, timestamp: String ): Boolean { return spaceClient.verifyWithPublicKey(body, timestamp.toLong(), signature) } // get user by Id and send message to the user // spaceClient gives you access to any Space endpoint // CallContext is shown as an error as we haven't created it yet suspend fun sendMessage(context: CallContext, message: ChatMessage) { spaceClient.chats.messages.sendMessage( MessageRecipient.Member(ProfileIdentifier.Id(context.userId)), message ) }
    Notes:

    • spaceClient is our API client – an instance of the SpaceHttpClientWithCallContext class. WithCallContext stays here for a reason. Such a client allows working with the call context – additional data that clarifies who made the call, how it was made, and so on. We will use the context to get the ID of the user who made the request.

    • SpaceHttpClient(HttpClient()).withServiceAccountTokenSource defines how the client will authenticate in Space: withServiceAccountTokenSource is used for the Client Credentials Flow. Other options would be withCallContext - for Refresh Token Flow and withPermanentToken for Permanent Token Authorization and other flows. HttpClient() here is a Ktor HTTP client.

    • clientId and clientSecret stand for the ID and secret we got during application registration.

    • url stands for the URL of your Space instance.

    • verifyRequestWithPublicKey() 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.

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

      • CallContext is our own class that will store call context. We'll create it in the next step.

      • ChatMessage is the API class that describes a Chat message. The thing is that 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 Builder. 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 directly. Thus, if you want to get a member profile, you can refer directly 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
  3. Let's create the CallContext class: It will describe the context of the request send by a user. As our chatbot is very simple, we will require only the ID of the user who made a request – we will use this ID to send messages back to the user. In more complex cases, you can, for example, use the ID to get the full member info from Space and, based on that info, decide what chatbot functionality the user is allowed to use.

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

    package com.remindme import space.jetbrains.api.runtime.types.* // here can be any context info, e.g. user info, payload, etc. class CallContext( val userId: String ) // get userId from the payload fun getCallContext(payload: ApplicationPayload): CallContext { val userId = when (payload) { is ListCommandsPayload -> payload.userId ?: error("no user for command") is MessageActionPayload -> payload.userId is MessagePayload -> payload.userId else -> error("unknown command") } return CallContext(userId) }

    We'll talk about the types of payload in the next steps.

  4. 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 com.remindme import space.jetbrains.api.runtime.helpers.message import space.jetbrains.api.runtime.types.* // command for showing chatbot help suspend fun commandHelp(context: CallContext) { sendMessage(context, helpMessage()) } // build the help message using special DSL fun helpMessage(): ChatMessage { return message { section { text("Soon the help will be shown here!") } } }

    Notes:

    • commandHelp(context: CallContext) is what our command actually does – it sends message with help back to the user.

    • helpMessage(): ChatMessage: The API client provides the special message DSL for building chat messages. As you can see, the help message consists of one section with text.

  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. Server functionality in Ktor is handled by the routing feature. Let's use this feature to handle POST requests on the /api/myapp endpoint.

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

    package com.remindme import io.ktor.application.* import io.ktor.http.* import io.ktor.request.* import io.ktor.response.* import io.ktor.routing.* import space.jetbrains.api.runtime.types.* import space.jetbrains.api.runtime.helpers.command import space.jetbrains.api.runtime.helpers.readPayload fun Routing.api() { post("api/myapp") { // 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") // verify the request val verified = signature != null && timestamp != null && verifyRequestWithPublicKey(body, signature, timestamp) if (!verified) { call.respond(HttpStatusCode.Unauthorized) return@post } // read payload and get context (user id) val payload = readPayload(body) val context = getCallContext(payload) // analyze the message payload // MessagePayload = user sends a command // ListCommandsPayload = user types a slash or a char when (payload) { is MessagePayload -> { commandHelp(context) call.respond(HttpStatusCode.OK, "") } is ListCommandsPayload -> { } } } }

    What is going on here:

    • 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 when a user types something in the chat and presses Enter.

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

    • Important: As you can see, in case of MessagePayload, the bot responds with the 200 OK HTTP status. If it doesn't, the user will get the 500 Internal Server Error.

  2. Let's configure the routing feature. Open the Routing.kt file in the Plugins directory and edit it as shown below:

    package com.remindme.plugins import com.remindme.api import io.ktor.application.* import io.ktor.routing.* fun Application.configureRouting() { routing { // call the api() function that handles our application endpoint api() } }
  3. Make sure the main() application function starts the server with enabled routing:

    fun main() { embeddedServer(Netty, port = 8080) { configureRouting() }.start(wait = true) }
  4. 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 com.remindme import space.jetbrains.api.runtime.types.* class Command( val name: String, val info: String, val run: suspend (context: CallContext, payload: MessagePayload) -> Unit ) { // part of the protocol - returns info about a command to the chat fun toCommand() = CommandDetail(name, info) } // list of available commands val commands = listOf( Command( "help", "Show this help", ) { context, payload -> commandHelp(context) }, Command( "remind", "Remind me in N seconds, e.g., to remind in 10 seconds, send 'remind 10' ", ) { context, payload -> commandRemind(context, payload) } ) // this is a response to the ListCommandsPayload // the bot must return a list of available commands // when a user types slash or a char fun commandListAllCommands(context: CallContext) = Commands( commands.map { it.toCommand() } )

    Here:

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

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

    • When a bot receives ListCommandsPayload, it will respond with commandListAllCommands that returns a map of CommandDetail.

      In fact, Space sends ListCommandsPayload every time a user presses a key. If it's the / slash, Space will show the full list of commands. If it's some other key, Space will find and show only commands containing the key.

      Slash commands
  2. Create the CommandRemind.kt file:

    package com.remindme import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import space.jetbrains.api.runtime.helpers.message import space.jetbrains.api.runtime.helpers.commandArguments import space.jetbrains.api.runtime.types.* suspend fun commandRemind(context: CallContext, payload: MessagePayload) { val args = payload.commandArguments() val delayMs = args?.toLongOrNull()?.times(1000) runTimer(context, delayMs) } private suspend fun runTimer(context: CallContext, delayMs: Long?) { if (delayMs != null) { sendMessage(context, acceptRemindMessage(delayMs)) // we don't want to interrupt the thread, // so, we'll put our delay inside coroutineScope coroutineScope { delay(delayMs) sendMessage(context, remindMessage(delayMs)) } } else { sendMessage(context, helpMessage()) } } fun acceptRemindMessage(delayMs: Long): ChatMessage { return message { outline = MessageOutline( icon = ApiIcon("smile"), text = "I will remind you in ${delayMs / 1000} seconds" ) } } fun remindMessage(delayMs: Long): ChatMessage { return message { outline = MessageOutline( icon = ApiIcon("smile"), text = "Hey! ${delayMs / 1000} seconds are over!" ) } }

    Here:

    • commandRemind runs the timer for the specified amount of time. payload.commandArguments() gets the arguments of the remind command. So, when a user types remind 10, the arguments are 10.

    • runTimer 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 ChatMessage. Both don't have body but only an outline. MessageOutline is a small block that precedes the main message body. So, for a user, this will be shown as a tiny message written in a small font. The ApiIcon returns an icon image that will be shown in front of the message.

  3. Edit the CommandHelp.kt file:

    package com.remindme import space.jetbrains.api.runtime.helpers.message import space.jetbrains.api.runtime.types.* suspend fun commandHelp(context: CallContext) { sendMessage(context, helpMessage()) } fun helpMessage(): ChatMessage { return message { outline = MessageOutline( icon = ApiIcon("smile"), text = "Remind me bot help" ) section { header = "List of available commands" fields { commands.forEach { field(it.name, it.info) } } } } }

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

  4. Edit the Routes.kt file:

    package com.remindme import com.fasterxml.jackson.databind.ObjectMapper import io.ktor.application.* import io.ktor.http.* import io.ktor.request.* import io.ktor.response.* import io.ktor.routing.* import kotlinx.coroutines.launch import space.jetbrains.api.runtime.types.* import space.jetbrains.api.runtime.helpers.command import space.jetbrains.api.runtime.helpers.readPayload fun Routing.api() { post("api/myapp") { val body = call.receiveText() val signature = call.request.header("X-Space-Public-Key-Signature") val timestamp = call.request.header("X-Space-Timestamp") val verified = signature != null && timestamp != null && verifyRequestWithPublicKey(body, signature, timestamp) if (!verified) { call.respond(HttpStatusCode.Unauthorized) return@post } val payload = readPayload(body) val context = getCallContext(payload) // JSON serializer val jackson = ObjectMapper() when (payload) { is ListCommandsPayload -> { call.respondText( jackson.writeValueAsString(commandListAllCommands(context)), ContentType.Application.Json ) } is MessagePayload -> { val command = commands.find { it.name == payload.command() } if (command == null) { commandHelp(context) } else { launch { command.run(context, 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 jackson 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:

      Help command
    • 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: 17 December 2021