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

    • To the repositories section:

      maven { url "https://maven.pkg.jetbrains.space/public/p/ktor/eap" } 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.3' // we use version 99662, but when you read this tutorial // a newer version may be already available implementation "org.jetbrains:space-sdk-jvm:99662-beta" implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-cio:$ktor_version"
  2. We're not going to write any tests for this application, so clean up all testImplementation dependencies from the dependencies section and delete the src/test folder in the project root.

    Your build.gradle should look like follows:

    plugins { id 'application' id 'org.jetbrains.kotlin.jvm' version '1.6.21' } group "com" version "0.0.1" mainClassName = "com.ApplicationKt" repositories { mavenCentral() maven { url "https://maven.pkg.jetbrains.space/public/p/ktor/eap" } 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" implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.13.3' implementation "org.jetbrains:space-sdk-jvm:99662-beta" implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-cio:$ktor_version" }

  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 tunnelling 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 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 will 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

In order our chatbot could communicate with Space, we must configure it in Space: register an application representing our bot, choose authorization flow for it, and so on. 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 or on behalf of a particular Space user. This determines which authorization flow 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 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. Learn more about requesting permissions.

    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 the tunnelling service for our bot. Let's make this endpoint less generic and add a postfix 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 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.ChatMessage import space.jetbrains.api.runtime.types.MessageRecipient 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() // Client for communication with Space. // The client uses the Client Credentials OAuth flow. val spaceClient by lazy { SpaceClient(spaceHttpClient, spaceAppInstance, SpaceAuth.ClientCredentials()) } // Get user by ID and send 'message' to the user. // 'spaceClient' gives you access to any Space endpoint. // 'CallContext' contains context data - ID of the user who made the call. // '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:

    • 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 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 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 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/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")?.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 } // 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:

    • 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.

    • 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.server.routing.* import io.ktor.server.application.* fun Application.configureRouting() { routing { // call the api() function that handles our application endpoint api() } }
  3. 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) }
  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 { MessageOutlineLegacy( icon = ApiIcon("smile"), text = "I will remind you in ${delayMs / 1000} seconds" ) } } fun remindMessage(delayMs: Long): ChatMessage { return message { MessageOutlineLegacy( 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 { MessageOutlineLegacy( icon = ApiIcon("smile"), text = "Remind me bot help" ) section { text("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.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/myapp"){ val body = call.receiveText() val signature = call.request.header("X-Space-Public-Key-Signature") val timestamp = call.request.header("X-Space-Timestamp")?.toLongOrNull() if (signature.isNullOrBlank() || timestamp == null || !spaceClient.verifyWithPublicKey( body, timestamp, signature ) ) { 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: 01 July 2022