JetBrains Space EAP Help

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 useful" 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 the ngrok tunnelling service for this purpose. To start working with ngrok, you should download the ngrok client. For our purposes, the free plan for ngrok is enough.

Step 1. Create a Ktor project

  1. Open Intellij IDEA.

  2. We are going to create a project that heavily uses Ktor, so the best way to do this is to use a Ktor template. The plain vanilla IDEA doesn't have Ktor templates – to make them appear, we must install the Ktor plugin. To do this, go to File | Settings | Plugins. Then find and install the Ktor plugin. Restart IDEA if needed.

    Ktor plugin for IDEA

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

  4. In the list of templates, select Ktor.

  5. In Client, select HTTP Client Engine. This will add a dependency on the Ktor HTTP client which we will use to communicate with Space.

  6. Leave other settings as is and proceed with Next.

    Create a Ktor project

  7. Specify a group and artifact ID, a project name, say remind-me-bot, and click Finish in the end.

    Finish creating a project

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

    New Ktor project

Step 2. Get Space SDK

  1. Open build.gradle and add:

    • To the repositories section:

      maven { url 'https://kotlin.bintray.com/kotlinx' } maven { url "https://maven.pkg.jetbrains.space/public/p/space-sdk/maven" }

    • To the dependencies section, add a dependency on the Space SDK for JVM and FasterXML/jackson (we'll need it to work with JSON payloads):

      compile "com.fasterxml.jackson.module:jackson-module-kotlin:2.11.3" compile "org.jetbrains:space-sdk-jvm:59455-beta"

    Your build.gradle should look like follows:

    buildscript { repositories { jcenter() } dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } apply plugin: 'kotlin' apply plugin: 'application' group 'com.remindme' version '0.0.1' mainClassName = "io.ktor.server.netty.EngineMain" sourceSets { main.kotlin.srcDirs = main.java.srcDirs = ['src'] test.kotlin.srcDirs = test.java.srcDirs = ['test'] main.resources.srcDirs = ['resources'] test.resources.srcDirs = ['testresources'] } repositories { mavenLocal() jcenter() maven { url 'https://kotlin.bintray.com/ktor' } maven { url 'https://kotlin.bintray.com/kotlinx' } maven { url 'https://maven.pkg.jetbrains.space/public/p/space-sdk/maven'} } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "io.ktor:ktor-server-netty:$ktor_version" implementation "ch.qos.logback:logback-classic:$logback_version" implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-core-jvm:$ktor_version" testImplementation "io.ktor:ktor-server-tests:$ktor_version" compile "com.fasterxml.jackson.module:jackson-module-kotlin:2.11.3" compile "org.jetbrains:space-sdk-jvm:59455-beta" }

  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 obtain 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 administration.png 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 Requested rights tab. We will not change anything on this tab – as our bot is very simple, it 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 rights are required for a certain HTTP API call, go to HTTP API Playground.

    Requested rights
  6. Open the Authentications tab. Our chatbot will authenticate on behalf of itself using the Client Credentials Flow. This means that we should not provide any additional information on this page. All we need to do is to save application's Client ID and Client secret. Our chatbot will use them to authenticate in Space.

    Authentications
  7. Open the Endpoint tab and then click the Generate verification token button. This will generate the token that will allow our bot to verify Space. Save it somewhere.

    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/back-to-space. So, the final endpoint would be https://{random_string_from_ngrok}.ngrok.io/api/back-to-space

    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 io.ktor.client.engine.cio.* import space.jetbrains.api.runtime.SpaceHttpClient import space.jetbrains.api.runtime.resources.chats import space.jetbrains.api.runtime.types.* import space.jetbrains.api.runtime.withServiceAccountTokenSource import space.jetbrains.yana.verifyWithToken private val url = "https://mycompany.jetbrains.space" private val clientId = "copy-paste-id-from-space" private val clientSecret = "copy-paste-secret-from-space" private val verificationToken = "copy-paste-token-from-space" // client for communication with Space val spaceClient by lazy { SpaceHttpClient(HttpClient()) .withServiceAccountTokenSource( clientId = clientId, clientSecret = clientSecret, serverUrl = url ) } // verification of Space instance fun verifyPayload(payload: ApplicationPayload) : Boolean { return payload.verifyWithToken(verificationToken) } // get user by Id and send message to the user 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 Personal Token Authorization and other flows.
      HttpClient() here is a Ktor HTTP client.

    • clientId, clientSecret, and verificationToken stand for the ID, secret, and verification token we obtained during application registration.

    • url stands for the URL of your Space instance.

    • verifyPayload(payload: ApplicationPayload) verifies the Space instance from which the client receives a request. It takes the message payload and compares token from the payload with the verificationToken variable. We will talk about the types of payload later in this tutorial.

    • 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 HTTP API Playground: The top-level titles here are the modules that can be accessed.:
        HTTP 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 suspend 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. Creating server functionality in Ktor starts with installing the Routing feature.

    To install routing, open Application.kt and edit it as shown below:

    package com.remindme import io.ktor.application.* import io.ktor.routing.* fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args) @Suppress("unused") // Referenced in application.conf @kotlin.jvm.JvmOverloads fun Application.module(testing: Boolean = false) { install(Routing) { backToSpace() } }
  2. Now, we must teach our bot to handle POST requests on the /api/back-to-space 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.yana.command import space.jetbrains.yana.readPayload fun Routing.backToSpace() { post("api/back-to-space"){ // read payload and verify Space instance val payload = readPayload(call.receiveText()).also { if (!verifyPayload(it)) { call.respond(HttpStatusCode.Unauthorized) return@post } } val context = getCallContext(payload) 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.

  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. Start our application by clicking Run in the gutter next to the main function in Application.kt:

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

    Find the bot
  3. If you look into the code, you can see that we don't anyhow analyze the commands sent by the user. On any type of 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
  4. 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) } 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 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.types.* import space.jetbrains.yana.commandArguments 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.yana.command import space.jetbrains.yana.readPayload fun Routing.backToSpace() { post("api/back-to-space") { val payload = readPayload(call.receiveText()).also { if (!verifyPayload(it)) { call.respond(HttpStatusCode.Unauthorized) return@post } } val context = getCallContext(payload) 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: 20 November 2020