Write Slack slash commands with Clime (TypeScript + Node.js)

Slack slash commands is one of the major interfaces interacting with a Slack integration. It is quite easy to integrate different functionalities with Slack slash commands. And this post will introduce a practice using TypeScript command-line interface framework Clime.

Please note that the following content is not by any mean a step-by-step tutorial. If you don't get something (e.g. Nginx configuration), try to Google it yourself.

Setting up server and application

Slack triggers commands by sending requests to given URLs, so we need a public server that can be accessed from internet.

During the development, we may setup port forwarding using SSH to forward a port on the public server to local computer for smoother debugging. Take Nginx an example, assuming we are going to listen port 10047 for this application, we could have a configuration file like this:

server {
  server_name [domain_name];

  listen 80;

  location / {
    proxy_pass http://127.0.0.1:10047;
  }
}

Execute an SSH command on your local computer and forward remote port 10047 to local:

ssh -R 10047:127.0.0.1:10047 [user]@[server]

Now let's write a simple Express application and listen on port 10047:

src/main.ts

import * as express from 'express';

let app = express();

app.post('/api/slack/command', async (req, res) => {
  res.send('hello, world!');
});

app.listen(10047);

Compile and start the app, and make sure we can access the API from the internet.
Once that is done, we are already able to add a valid Slack slash command. How simple is that!

Open Building Slack apps page, create an app, and add a slash command /demo with proper request URL. Make sure to have the "Escape channels, users, and links sent to your app" option checked:

slash-command

Save the command and switch to Slack client, execute command /demo. And if things went well, you should see Slack responds with the "hello, world!" message:

hello-world-result

Now we'll add body-parser package for parsing command arguments:

src/main.ts

import * as BodyParser from 'body-parser';
import * as express from 'express';

let app = express();

app.use(
  BodyParser.urlencoded({
    extended: false,
  }),
);

app.post('/api/slack/command', async (req, res) => {
  console.log(req.body);
  res.send('hello, world!');
});

app.listen(10047);

Update the app and execute /demo command again, and the app console should print infomation like this:

{ token: '[token]',
  team_id: '[team_id]',
  team_domain: '[team_domain]',
  channel_id: '[channel_id]',
  channel_name: 'makeflow-dev',
  user_id: '[user_id]',
  user_name: 'vilic',
  command: '/demo',
  text: '',
  response_url: '[response_url]',
  trigger_id: '[trigger_id]' }

You may write down the token here for later use (of course you can always find it on your Slack app page as well).

Writing commands with Clime

You may probably have heard of libraries like commander.js and yargs, but not Clime yesterday. However if you want to develop some sort of CLI tools in TypeScript, Clime could be a nice choice.

Clime is an "object-oriented" command-line interface framework, and its core is not coupled with concrete user interfaces. To build Slack slash commands with Clime, the only thing we need is a shim against Slack. And here we are going to add two npm packages clime and clime-slack, and update the app code as below:

src/main.ts

import * as Path from 'path';

import * as BodyParser from 'body-parser';
import * as express from 'express';

import {CLI} from 'clime';
import {SlackShim} from 'clime-slack';

let cli = new CLI('/', Path.join(__dirname, 'commands'));
let shim = new SlackShim(cli /*, [token]*/);

let app = express();

app.use(
  BodyParser.urlencoded({
    extended: false,
  }),
);

app.post('/api/slack/command', async (req, res) => {
  let result = await shim.execute(req.body);
  res.json(result);
});

app.listen(10047);

Now let's create the command file for /demo:

src/commands/demo.ts

import {Command, command, param} from 'clime';
import {SlackUser} from 'clime-slack';

@command({
  description: 'This is a command for printing a greeting message',
})
export default class extends Command {
  execute(
    @param({
      description: 'A slack user',
      required: true,
    })
    user: SlackUser,
  ) {
    return `Hello, ${user}, your ID is ${user.id}!`;
  }
}

Clime requires the compilation target to be es6 or higher, and due to the fact it's using decorators and docorator metadata, we'll need to update tsconfig.json accordingly:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "rootDir": "src",
    "outDir": "bld",
    ...
  }
}

Compile and restart the app, and execute command /demo, we'll get the following result:

demo-command

And of course you can execute /demo --help to gently retrieve the help content.

The error is caused by parameter user being required in this case. If we try something like /demo @vilic (please use an existing username in your organization), we'll have the desired result:

demo-command-user

Here we are using SlackUser provided by clime-slack that implements StringCastable interface of Clime. The argument <@user_id|user_name> sent by Slack is automatically casted into a SlackUser instance.

Package clime-slack also provides another similar SlackChannel class, as well as other utilties like SlackCommandContext.

Please check out the Clime project for more common castable stuffs and usages.

It is rather easy to add a new command now. Just create file src/commands/[command-name].ts with similiar content in demo.ts file for the new command, and add the correspondant command in your Slack app.

Getting online

By now we are still running the app locally, but I believe it won't be a problem for you to get it online. To ensure recovery on crashes and system reboots, we may use tools like pm2 to manage our app:

mkdir /var/www/slack-integration
cd /var/www/slack-integration

git clone [...] .

yarn install
yarn build

pm2 start bld/main.js
pm2 startup
pm2 save

And now you may just enjoy the new Slack slash commands integration!