Writing Slack bot in Crystal programming language

16 minutes read

Crystal is a young statically typed programming language which is intended to be very fast (because of compile-time evaluation) and which has a very readable syntax similar to Ruby. Crystal is still not production ready and often introduce breaking changes during new releases. That means it can become painful to maintain a big codebase written in Crystal.

However, I personally think Crystal can be very suitable for small microservices. Just because the language is very fast and the microservice utilizes quite a small piece of code.

One of such simple examples we use in our company is a Slack bot, which helps people to find some project related information directly in Slack without bothering colleagues.

In this article you will find how to write such a bot in Crystal, deploy it and install to your slack workspace.

Bot definition

To extend some interactivity, Slack introduced slash commands. Basically it acts as a shortcut for some specific action directly in Slack. There are built-in commands and custom ones are allowed too.

Basically, if we would like to create a new slash command, we would need to have a standalone microservice available on the internet, which could handle the HTTP requests from Slack when users execute such a slash command.

So let’s give it a try. We will create /prince slash command which accepts some arguments and prints project related information to on-board our newcomers.

Start a new project

From the very beginning we would need to generate a new Crystal application:

$ crystal init app prince-slack_bot

It creates a new project skeleton for our app with a couple of important files/folders:

$ tree prince-slack_bot

prince-slack_bot
├── LICENSE
├── README.md
├── shard.yml
├── spec
│   ├── prince-slack_bot_spec.cr
│   └── spec_helper.cr
└── src
    └── prince-slack_bot.cr

2 directories, 6 files
  • shard.yml - this is where we will define our project specific settings, like a version of Crystal to run the app on, the version of our app, dependencies etc
  • src/ - a folder that holds our sources and the target to run
  • spec/ - tests for the sources

Let it serve

Our slack bot will have to be running as a standalone server, accept HTTP requests and respond to them. In order to do that we could use the HTTP Server available in the stdlib. However, it is quite minimalistic and lacks a couple of important features.

A more advanced alternative is Kemal, a defacto fast and effective web framework which perfectly matches our requirements (to build a microservice). We can easily add it to shard.yml as a project dependency:

dependencies:
  kemal:
    github: kemalcr/kemal

And install through the shards install command.

At this point, we are ready to create a serveable app, which could respond to HTTP requests. Let’s create src/app.cr file (which is will become a starting point for our app) and implement a server using Kemal:

require "kemal"

get "/" do
  "Prince Slack Bot is alive"
end

post "/command" do |env|
  env.response.content_type = "application/json"

  # TODO: process env.params and respond
  ({} of String => String).to_json
end

port = ENV["PORT"]?.try(&.to_i) || 3000
Kemal.run(port)

Here we defined 2 endpoints:

  1. GET / - shows that our app is alive. Will be helpful to ensure our app is running once deployed.
  2. POST /command - an actual endpoint to process a command from the slack app. Will accept JSON params and respond with JSON content.

We can easily try running our dummy app:

$ crystal src/app.cr
[development] Kemal is ready to lead at http://0.0.0.0:3000
2020-03-14 20:18:00 UTC 200 GET / 49.78µs

And see whether it works in a browser:

Process Slack requests

We ran a simple HTTP server which is able to accept commands through the /command endpoint. This is a time to add an ability to process them and return some results.

For slash commands, Slack uses text as a request body parameter to pass everything was typed after the command. For example, if user types /prince hello world in Slack, our server will be hitted by the HTTP request having hello world in text body param.

So we would just need to take the text param and parse it into something we can run:

module Prince::SlackBot
  def self.process(request)
    text = request.body["text"]
    parse(text).run
  end

  def self.parse(text)
    # TODO: parse command args into the runnable commands
  end
end

And at this point we can change our handler to process /command endpoint in src/app.cr file and use just defined high level command processor:


# src/app.cr

post "/command" do |env|
  env.response.content_type = "application/json"

-  # TODO: process env.params and respond
-  ({} of String => String).to_json
+  Prince::SlackBot.process(env.request).to_json
end

Define commands

We would like to define a notion of command, e. g. a slack user will have to type /prince cmd args, where cmd is a predefined command by our bot, and it accepts some arguments args.

In order to do so, we can just split our text HTTP parameter, extract command (the first word) the its arguments (the rest) and instantiate such a command:

def self.parse(text)
-  # TODO: parse command args into the processable commands
+  words = (text || "").split(' ', remove_empty: true)
+  cmd, args = words[0]?, words[1..-1]?

+  case cmd
+  when "help"
+    Command::Help.new args
+  when "status"
+    Command::Status.new args
+  when # a bag of other commands go here
+  else
+    Command::Help.new args
+  end
end

Command on other hand can be just a class, which accepts the arguments during initialization and responds to the #run method:

module Prince::SlackBot::Command
  class Help
    def initialize(@args = [] of String)
    end

    def run
      { "text" => help }
    end

    private def help
      <<-TEXT
      *Usage*: `/prince cmd args`
      *Available commands:*
       `help`   - prints this help
       `status` - prints the status of the prince app (Up/Down)
       # ...
      TEXT
    end
  end
end

Similar to requests, Slack expects JSON response with the text attribute inside. For the help command above we just send the help information in the text attribute.

Similar to the Help we can define other commands (to show the status of our app, to print links to GitHub repositories etc.)

Deploy

We need our app to be open to the world in order handle Slack requests. So we need to deploy it somewhere. The simplest way to deploy Crystal apps is using Heroku.

There is a great article which explains how to deploy Crystal apps using Crystal Heroku Buildpack. At some point we just need to push our code to heroku origin:

$ git push heroku master
Counting objects: 8, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (17/17), done.
Writing objects: 100% (8/8), 0.94 KiB | 0 bytes/s, done.
Total 8 (delta 8), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Fetching set buildpack https://github.com/crystal-lang/heroku-buildpack-crystal.git... done
remote: -----> Crystal app detected
remote: -----> Installing Crystal (0.31.0 due to latest release at https://github.com/crystal-lang/crystal)
remote: -----> Installing Dependencies
remote: -----> Compiling src/app.cr (auto-detected from shard.yml)
remote:
remote: -----> Discovering process types
remote:        Procfile declares types     -> (none)
remote:        Default types for buildpack -> web
remote:
remote: -----> Compressing...
remote:        Done: 289.4K
remote: -----> Launching...
remote:        Released v3
remote:        https://prince-slack-bot.herokuapp.com deployed to Heroku
remote:
remote: Verifying deploy.... done.
To https://prince-slack-bot.herokuapp.com.git
 * [new branch]      master -> master

As we can see, it was successfully deployed and we can check our app availability at https://prince-slack-bot.herokuapp.com.

Configure Slack APP

Our bot is ready to handle Slack requests. However, Slack should know about it. There are a couple of steps to do here.

  1. Create a new Slack app in the Slack workspace:

  1. Define a Slack slash command filling a command name, request URL (the URL our bot is available at), description and some help information which will be shown to users:

  1. Now, when the app is activated and becomes available as a slash command in our workspace, we can try typing /prince, hit enter and see the results.

Wrap up

In this article we showed how to create a Slack bot written in Crystal programming language, deployed it to Heroku and configured Slack application to interact with it.

In next articles we will talk about how to properly sign off Slack requests and write tests for our app.

Leave a Comment