Tutorial

Building a Slack Bot with Modern Node.js Workflows

Draft updated on Invalid Date
Default avatar

By Calvin Karundu

Building a Slack Bot with Modern Node.js Workflows

This tutorial is out of date and no longer maintained.

Introduction

I recently started getting more requests from my workmates to refresh a “particular report” through one of the many custom reporting scripts we have. At first, I didn’t mind, but eventually, I felt it would be better if people could get these reports themselves. There was no need to have this back and forth before getting a report, plus I’m certain I’m not the only developer who frowns upon the idea of being plugged out of my coding thoughts.

I decided to build a Slack app using Node.js that anyone at work could use to get reports on demand. This would save me from doing mundane reporting tasks and make for a better experience for everyone involved.

reporterbot demo

In this tutorial, we’ll be building a Slack app called reporterbot that provides functionality to get reports on demand. Our app will need to allow a user to state what report they want, generate the actual report, and finally send it back to the user. Aside from building the actual Slack app, this tutorial will also cover some modern practices when working on Node.js applications.

Prerequisites

Before getting started you will need to have Node and npm installed on your machine. Node Version Manager can help you manage your Node and npm versions.

You’ll also need to have basic knowledge of using the Express web framework as well as ngrok or any other secure tunneling service to make your localhost publicly reachable.

Create Slack App

Our bot will be packaged as a Slack app. Slack apps allow you to add more functionality to your Slack workspace. In our case, we’ll be adding our custom reporting functionality.

To create your new Slack app, go to this page and set your app name as well as the workspace you’ll be developing your app in. If you don’t have a workspace, you can create one here.

Set App Name and Select Development Workspace

Once you finish this step, you’ll have a new Slack app to manage.

Enable Slack App Features

Next, we’ll need to configure our new Slack app with the features we’ll be using to achieve our needed functionality. For our reporterbot to work, we’ll need to enable the following features:

  • Bot Users - This will enable our app to interact with users in a more conversational manner
  • Slash Commands - This will allow users to invoke app methods that we expose. We’ll use this to allow the user to initiate the get report flow (on demand)
  • Interactive Components - This will help us make the experience more interactive using components like drop-down menus to help the user select a report instead of having them type it in

Slack App Features

To enable bot users, click on the Bots feature button as shown in the image above. Set your bots Display Name and Default Username to reporterbot or whatever name you find fitting.

For slash commands and interactive components to work, in our app, we’ll need to provide webhooks that slack can post to. These features involve actions that are initiated by the user inside Slack. Every time an action happens through a slash command or interactive component, Slack will make a POST request to our registered webhook and our application will need to respond accordingly.

To enable slash commands, click on the Slash Commands feature button as shown in the image above. We’ll create a new slash command called /report and set the webhook to our server-side endpoint that will handle this action:

https://your-ngrok-domain-here/slack/command/report

Whenever a user sends a message beginning with /report, slack will make a POST request to the configured webhook and our app will initiate the get report flow.

Once the get report flow is initiated, we’ll make use of Interactive Components to allow the user to select the report they want.

To enable interactive components, click on the Interactive Components feature button as shown in the image above and set the webhook to our server-side endpoint that will handle this action:

https://**your-ngrok-domain-here**/slack/actions

Enable Bot Users

Enable Slash Commands

Enable Interactive Components

Install Slack App to workspace

With our features configured, we’ll now need to install the app into our Slack workspace. This will make the app available to the users in the workspace plus generate all the necessary tokens our application will need to make authenticated requests to Slack’s API.

Install App to workspace

After installing the app to your workspace, click the OAuth & Permissions menu item available to the right of the screen to get the authentication tokens we’ll be using. Our application will only be making use of the Bot User OAuth Access Token, copy it, and save it privately.

Get Bot Access Token

Set Up Node.js Application

Great! Now that our Slack app is successfully configured and installed, we can begin working on the Node.js application. Let’s begin by recapping what the scope of our Node.js application will be. It will need to:

  1. Process POST requests from Slack as a result of our users sending the /report slash command or selecting a report through an interactive component
  2. Generate the selected report
  3. Send the report back to the user

Open your terminal and create a new folder called reporterbot or whatever name you find fitting. Navigate into the folder and initialize the project by running npm init. This will give you a few prompts, no need to answer all of them, just keep pressing enter to use the default values.

  1. mkdir reporterbot
  2. cd reporterbot
  3. npm init

Babel and ESlint Installation and Configuration

We’ll be building our Node.js application using some sweet ES6 features. ES6 has been out for quite some time and learning the new syntax and features will make you a more productive Node.js developer.

Some of the ES6 features we’ll be using are not supported in older Node.js environments. To solve this, we’ll use Babel - a JavaScript transpiler - to convert our ES6 code into plain old ES5 which is more widely supported. Install Babel by running:

  1. npm i babel-cli babel-preset-env babel-polyfill

We need all the above dependencies to work with Babel. babel-preset-env is a handy tool that makes configuring Babel a breeze. babel-polyfill as the name suggests, provides polyfills for features that are not supported in older environments.

Create a new file, .babelrc in the project root folder and copy the following into it:

{
  "presets": [
    ["env", {
      "targets": {
        "node": "current"
      }
    }]
  ]
}

The Babel configuration file is quite straightforward thanks to babel-preset-env. We’re instructing Babel to convert only what’s necessary for the current Node.js version installed in our machines. Check out their docs for more information.

Up next, we’re going to set up ESlint. From their documentation site:

“JavaScript code is typically executed in order to find syntax or other errors. Linting tools like ESLint allow developers to discover problems with their JavaScript code without executing it.”

ESlint improves your development experience by helping you catch bugs early. ESLint also helps with enforcing a style guide. What is a style guide?

A style guide is a set of rules on how to write code for a particular project. Style guides are important to make sure you’re writing code that is visually consistent and readable. This makes it easier for other developers to understand your code. IMHO, writing clean, easy-to-read code makes you that much more of a professional, and more importantly, it makes you a better human being. Install ESlint by running:

  1. npm i eslint eslint-config-airbnb-base eslint-plugin-import

There’s a number of JavaScript style guides we can use. These style guides are mostly made by teams that have massive JavaScript codebases… Airbnb, jQuery, Google, etc.

eslint-config-airbnb-base will configure ESlint to run with the style guide provided by Airbnb; I find their style guide effective for my workflow but you’re free to use whatever style guide you want. eslint-plugin-import will set rules on how to use the new ES6 module import syntax.

Create a new file, .eslintrc in the project root folder and copy the following into it:

{
  "extends": "airbnb-base",
  "plugins": [
    "import"
  ],
  "env": {
    "browser":false,
    "node": true
  },
  "rules": {
    "indent": [2, 2],
    "import/no-extraneous-dependencies": [2, {
      "devDependencies": true
    }]
  }
}

The ESlint configuration file is also quite straight forward. We’re instructing ESlint to use the Airbnb style guide, to use the import plugin, setting the environment our project will be running in and setting a few other rules. Check out their docs for more information.

We’ve set up a pretty good base to build our application logic, before we jump into more code, our project file structure should look like this:

.
|-- node_modules
|-- .babelrc
|-- .eslintrc
|-- package-lock.json
|-- package.json

Node.js Application Logic

Let’s jump into our application logic. Create two folders config and src in the project root folder. config will have our configuration files and src will have our application logic. Inside config we’ll add a default.json file and a development.json file. Inside src we’ll add an index.js file.

  1. mkdir config src
  2. touch config/default.json config/development.json src/index.js

Our project file structure should now look like this:

.
|-- config
    |-- default.json
    |-- development.json
|-- node_modules
|-- src
    |-- index.js
|-- .babelrc
|-- .eslintrc
|-- package-lock.json
|-- package.json

Open config/default.json and copy the following into it:

{
  "baseUrl": "http://127.0.0.1:8000",
  "host": "127.0.0.1",
  "port": 8000,
  "reportFilesDir": "reportFiles"
}

Open conig/development.json and copy the following into it:

{
  "slack": {
    "fileUploadUrl": "https://slack.com/api/files.upload",
    "reporterBot": {
        "fileUploadChannel": "#reporterbot_files",
        "botToken": "YOUR-BOT-TOKEN-HERE"
    }
  }
}

Make sure to add the development.json file to your .gitgnore as your Slack tokens should be private. The file upload channel as you might have guessed is where we’ll eventually upload the report to. We’ll come back to this later.

We need to install a couple of modules:

  1. npm i express config morgan tracer csv-write-stream mkdirp

express as mentioned earlier is the web framework we’ll be using. The config module will help us manage our environment variables by reading the files in the config folder. morgan and tracer will provide us with logging functionality. csv-write-stream will help us write data into csv files and mkdirp will help us create folders dynamically.

Open src/index.js and copy the following into it:

import 'babel-polyfill';

import config from 'config';
import express from 'express';
import http from 'http';

import bootstrap from './bootstrap';
import { log, normalizePort } from './utils';

const app = express();
app.start = async () => {
  log.info('Starting Server...');
  const port = normalizePort(config.get('port'));
  app.set('port', port);
  bootstrap(app);
  const server = http.createServer(app);

  server.on('error', (error) => {
    if (error.syscall !== 'listen') throw error;
    log.error(`Failed to start server: ${error}`);
    process.exit(1);
  });

  server.on('listening', () => {
    const address = server.address();
    log.info(`Server listening ${address.address}:${address.port}`);
  });

  server.listen(port);
};

app.start().catch((err) => {
  log.error(err);
});

export default app;

We’ve created our express server but it won’t run yet as there are some missing files. There’s a bootsrap.js and utils.js file that we’re importing objects from but haven’t created yet. Let’s create those inside the src folder:

  1. touch src/utils.js src/bootstrap.js

utils.js as the name suggests has utility methods like logging that we’ll be using across the project. bootsrap.js is where we’ll set up our application routes.

Open src/utils.js and copy the following into it:

import fs from 'fs';
import path from 'path';
import config from 'config';
import csvWriter from 'csv-write-stream';
import morgan from 'morgan';
import mkdirp from 'mkdirp';
import tracer from 'tracer';

export const log = (() => {
  const logger = tracer.colorConsole();
  logger.requestLogger = morgan('dev');
  return logger;
})();

export const normalizePort = (val) => {
  const port = parseInt(val, 10);
  if (Number.isNaN(port)) return val;
  if (port >= 0) return port;
  return false;
};

export const delay = time => new Promise((resolve) => {
  setTimeout(() => { resolve(); }, time);
});

export const fileExists = async (filePath) => {
  let exists = true;
  try {
    fs.accessSync(filePath);
  } catch (err) {
    if (err.code === 'ENOENT') {
      exists = false;
    } else {
      throw err;
    }
  }
  return exists;
};

export const writeToCsv = ({ headers, records, filePath }) => {
  const writer = csvWriter({ headers });
  writer.pipe(fs.createWriteStream(filePath));
  records.forEach(r => writer.write(r));
  writer.end();
};

export const getReportFilesDir = () => {
  let reportFilesDir;
  try {
    reportFilesDir = path.join(__dirname, `../${config.get('reportFilesDir')}`);
    mkdirp.sync(reportFilesDir);
    return reportFilesDir;
  } catch (err) {
    throw err;
  }
};

log and normalizePort are the two objects we imported from the index.js file earlier. We’ll use delay, fileExists, writeToCsv, and getReportFilesDir later but from their names, you can guess what they’ll be used for.

Next, we need to configure our routes in the bootstrap.js file. Before doing this we’ll have to install a module called body-parser to help parse incoming request bodies.

  1. npm i body-parser

Now open src/bootstrap.js and copy the following into it:

import bodyParser from 'body-parser';

import { log } from './utils';
import routes from './routes';

export default function (app) {
  app.use(bodyParser.json());
  app.use(bodyParser.urlencoded({ extended: true }));

  // Routes
  app.use(routes);

  // 404
  app.use((req, res) => {
    res.status(404).send({
      status: 404,
      message: 'The requested resource was not found',
    });
  });

  // 5xx
  app.use((err, req, res) => {
    log.error(err.stack);
    const message = process.env.NODE_ENV === 'production'
      ? 'Something went wrong, we\'re looking into it...'
      : err.stack;
    res.status(500).send({
      status: 500,
      message,
    });
  });
}

There’s a routes.js file we’re importing the actual routes from but haven’t created yet. Let’s create it inside the src folder:

  1. touch src/routes.js

Open src/routes.js and copy the following into it:

import express from 'express';

import { log } from './utils';
import { reportsList } from './modules/reports';

const router = new express.Router();

router.post('/slack/command/report', async (req, res) => {
  try {
    const slackReqObj = req.body;
    const response = {
      response_type: 'in_channel',
      channel: slackReqObj.channel_id,
      text: 'Hello :slightly_smiling_face:',
      attachments: [{
        text: 'What report would you like to get?',
        fallback: 'What report would you like to get?',
        color: '#2c963f',
        attachment_type: 'default',
        callback_id: 'report_selection',
        actions: [{
          name: 'reports_select_menu',
          text: 'Choose a report...',
          type: 'select',
          options: reportsList,
        }],
      }],
    };
    return res.json(response);
  } catch (err) {
    log.error(err);
    return res.status(500).send('Something blew up. We\'re looking into it.');
  }
});

export default router;

Ok, that’s quite a bit of code, let’s break it down. Close to the top of the file we have this line:

import { reportsList } from './modules/reports';

We’re importing a reportsList that we’ll be using shortly. Notice we’re importing it from a reports module we haven’t created yet. We’ll do this soon.

The first route we create is /slack/command/report. This is the webhook that will handle the /report slash command; we configured this earlier in our Slack app. When a user sends a message beginning with /report, Slack will make a POST request to this route.

The first thing we do is capture what Slack has posted to our application in the slackReqObj. This object has a lot of data but all we need for now is the channel_id. This channel_id represents the channel the user on Slack sent the /report command on. This could be a public channel, private channel, or a direct message. Our app doesn’t really care at this point, all we need is the channel_id so that we can return the response to the sender.

Next, we construct our response object to send back to Slack. I’d like to highlight four keys within this object:

  • response.channel - This is where we set the channel we want to send our response to.
  • response.attachments - This is where we’ll add our interactive component to allow the user to select a report.
  • response.attachments[0].callback_id - We’ll use this later after the user selects a report from the interactive component.
  • response.attachments[0].actions - This is where we create our interactive component. It’s a select menu and its options are in the reportsList object that we’ll create shortly.

Let’s create that reportsList object. Inside the src folder, we’ll create a folder called modules, and inside modules we’ll add another folder called reports with an index.js file.

  1. mkdir src/modules src/modules/reports
  2. touch src/modules/reports/index.js

Open src/reports/index.js and copy the following into it:

import path from 'path';
import config from 'config';

import { log, delay, fileExists, getReportFilesDir } from '../../utils';

// Reports
import getUserActivity from './getUserActivity';

const slackConfig = config.get('slack');

const REPORTS_CONFIG = {
  userActivity: {
    name: 'User Activity',
    namePrefix: 'userActivity',
    type: 'csv',
    func: getUserActivity,
  },
};

export const reportsList = Object.entries(REPORTS_CONFIG)
  .map(([key, value]) => {
    const report = {
      text: value.name,
      value: key,
    };
    return report;
  });

Let’s break down the index.js file we just created. Think of this file as a control center for all the reports we could potentially have. Close to the top of the file, we import a function called getUserActivity which as you might have guessed is a function that will generate a user activity report. We haven’t made this function yet but we’ll do so shortly.

The REPORTS_CONFIG variable is an object that has data on all the available reports. Every key within this object maps to a particular report; In this case userActivity. The name, namePrefix, and type keys are going to come in handy when we’re uploading the report to Slack. In the func key, we store the function needed to generate the actual report. We’ll call this function later after the user selects a report.

To create the reportsList variable we use a handy little feature called Object.entries to loop through the REPORTS_CONFIG variable and get all available reports. Eventually reportsList will be an array that looks like this:

const reportsList = [{
  text: 'User Activity',
  value: 'userActivity'
}];

This is the reportsList object we used to configure our interactive component earlier. Every object in the reportsList corresponds to a select option the user will be presented with.

When we send this response back to Slack, our user should see a message that looks like this:

Begin get report flow

Select report

We’re halfway done, all we need to do is add the logic for what happens after a user selects a particular report. Before we move on, let’s create that getUserActivity function. This function will generate a simple csv report with some random user activity data. Inside the reports folder create a getUserActivity.js file:

  1. touch src/modules/reports/getUserActivity.js

Open getUserActivity.js and copy the following into it:

import { log, writeToCsv } from '../../utils';

const generateData = async ({ startDate, endDate, totalRecords }) => {
  try {
    const userActivity = [];
    for (let index = 0; index < totalRecords; index += 1) {
      userActivity.push({
        username: `user_${index + 1}`,
        startDate,
        endDate,
        loginCount: Math.floor(Math.random() * 20),
        itemsPurchased: Math.floor(Math.random() * 15),
        itemsReturned: Math.floor(Math.random() * 5),
      });
    }

    return userActivity;
  } catch (err) {
    throw err;
  }
};

export default async (options) => {
  try {
    const {
      startDate = '2017-11-25',
      endDate = '2017-11-28',
      totalRecords = 20,
      reportFilePath,
    } = options;

    const userActivity = await generateData({
      startDate,
      endDate,
      totalRecords,
    });

    if (userActivity.length > 0) {
      const headers = [
        'Username',
        'Start Date',
        'End Date',
        'Login Count',
        'Items Purchased',
        'Items Returned',
      ];

      const records = userActivity.map(record => [
        record.username,
        record.startDate,
        record.endDate,
        record.loginCount,
        record.itemsPurchased,
        record.itemsReturned,
      ]);

      const filePath = reportFilePath;
      writeToCsv({ headers, records, filePath });
      log.info(`${records.length} records compiled into ${filePath}`);
    }
  } catch (err) {
    throw err;
  }
};

All we’re doing here is generating some random data and organizing it in a format that the csv-write-stream module we installed earlier can consume. Notice we’re making use of the writeToCsv function we created earlier in the utils.js file. One more thing to point out at the top of the default function is the use of Object destructuring. This allows us to do all sorts of cool tricks like setting default values for our parameters.

OK, let’s keep moving. First, make sure your project file structure looks like this:

.
|-- config
    |-- default.json
    |-- development.json
|-- node_modules
|-- src
    |-- modules
        |-- reports
            |-- getUserActivity.js
            |-- index.js
    |-- bootstrap.js
    |-- index.js
    |-- routes.js
    |-- utils.js
|-- .babelrc
|-- .eslintrc
|-- package-lock.json
|-- package.json

The second route we’ll create is /slack/actions. This is the webhook that will handle actions on interactive components; we configured this earlier in our Slack app. When a user selects a report, Slack will make a POST request to this route. Copy the following into the routes.js file under the /slack/command/report route we just created:

router.post('/slack/actions', async (req, res) => {
  try {
    const slackReqObj = JSON.parse(req.body.payload);
    let response;
    if (slackReqObj.callback_id === 'report_selection') {
      response = await generateReport({ slackReqObj });
    }
    return res.json(response);
  } catch (err) {
    log.error(err);
    return res.status(500).send('Something blew up. We\'re looking into it.');
  }
});

Once again, we capture what Slack has posted to our app in the slackReqObj. Notice that this time around we derive the slackReqObj by parsing the payload key in the request body. Now we need to compose a response based on what action this request represents.

Remember the response.attachments[0].callback_id key I highlighted earlier, that’s what we use to figure out what action we’re responding to. The interactive components webhook can be used for more than one action, using this callback_id lets us know what action we’re handling.

If the callback_id === "``report_selection``", we know that this is an action from the select report interactive component we had sent out earlier; this means our user has selected a report. We need to give the user feedback that we have received their request and begin generating the report they selected.

To do this we’ll use a generateReport function. Calling this function will return a confirmation message that we send back to the user as well as invoke the report func we saved earlier in the REPORTS_CONFIG to begin generating the selected report.

Open src/reports/index.js and copy the following under the reportsList object:

export const generateReport = async (options) => {
  try {
    const { slackReqObj } = options;
    const reportKey = slackReqObj.actions[0].selected_options[0].value;
    const report = REPORTS_CONFIG[reportKey];

    if (report === undefined) {
      const slackReqObjString = JSON.stringify(slackReqObj);
      log.error(new Error(`reportKey: ${reportKey} did not match any reports. slackReqObj: ${slackReqObjString}`));
      const response = {
        response_type: 'in_channel',
        text: 'Hmmm :thinking_face: Seems like that report is not available. Please try again later as I look into what went wrong.',
      };
      return response;
    }

    const reportTmpName = `${report.namePrefix}_${Date.now()}.${report.type}`;
    const reportFilesDir = getReportFilesDir();
    const reportFilePath = path.join(reportFilesDir, reportTmpName);

    const reportParams = {
      reportName: report.name,
      reportTmpName,
      reportType: report.type,
      reportFilePath,
      reportFunc() {
        return report.func({ reportFilePath });
      },
    };

    // Begin async report generation
    generateReportImplAsync(reportParams, { slackReqObj });

    const response = {
      response_type: 'in_channel',
      text: `Got it :thumbsup: Generating requested report *${report.name}*\nPlease carry on, I'll notify you when I'm done.`,
      mrkdwn: true,
      mrkdwn_in: ['text'],
    };
    return response;
  } catch (err) {
    throw err;
  }
};

Now open the src/routes.js file and add the generateReport function in the same import statement as the reportsList

import { reportsList, generateReport } from './modules/reports';

The first thing we do in the generateReport function is figure out what report the user selected. The object Slack posts to the interactive component webhook has the selected value and we get it here slackReqObj.actions[0].selected_options[0].value;. We extract this value into a reportKey variable as it corresponds to the key that’s in our REPORTS_CONFIG. In our case, this will be userActivity. We use this key to get the report object from the REPORTS_CONFIG.

If the reportKey doesn’t match a particular report, we log an error and send the user a failure message. If it matches a report, we proceed to generate it.

First, we create a temporary name for the report in the reportTmpName variable using the current timestamp; the temporary name will look like this userActivity_1511870113670.csv. Next, we get the directory where we’ll be saving the report. We created a utility function earlier called getReportFilesDir for this. Finally, we compose the full reportFilePath by joining the reportFilesDir path and the reportTmpName

Next, we create a reportParams object with some metadata and the report function to call. We pass this object to a generateReportImplAsync function that we’ll create shortly. The generateReportImplAsync function will execute the function to generate the report asynchronously. It’s important we do it asynchronously as we don’t know how long the report generation will take. As the report is being generated we send the user a confirmation message.

Let’s create the generateReportImplAsync function. Copy the following above the generateReport function:

const generateReportImplAsync = async (options, { slackReqObj }) => {
  const {
    reportName,
    reportTmpName,
    reportType,
    reportFilePath,
    reportFunc,
  } = options;

  try {
    // Initiate report function
    await reportFunc();

    /*
      FIX ME::
      Delay hack to ensure previous fs call is done processing file
    */
    await delay(250);
    const reportExists = await fileExists(reportFilePath);

    if (reportExists === false) {
      const message = {
        responseUrl: slackReqObj.response_url,
        replaceOriginal: false,
        text: `There's currently no data for report *${reportName}*`,
        mrkdwn: true,
        mrkdwn_in: ['text'],
      };
      return postChatMessage(message)
        .catch((ex) => {
          log.error(ex);
        });
    }

    /*
      FIX ME::
      Delay hack to ensure previous fs call is done processing file
    */
    await delay(250);
    const uploadedReport = await uploadFile({
      filePath: reportFilePath,
      fileTmpName: reportTmpName,
      fileName: reportName,
      fileType: reportType,
      channels: slackConfig.reporterBot.fileUploadChannel,
    });
    const message = {
      responseUrl: slackReqObj.response_url,
      replaceOriginal: false,
      text: 'Your report is ready!',
      attachments: [{
        text: `<${uploadedReport.file.url_private}|${reportName}>`,
        color: '#2c963f',
        footer: 'Click report link to open menu with download option',
      }],
    };
    return postChatMessage(message)
      .catch((err) => {
        log.error(err);
      });
  } catch (err) {
    log.error(err);
    const message = {
      responseUrl: slackReqObj.response_url,
      replaceOriginal: false,
      text: `Well this is embarrassing :sweat: I couldn't successfully get the report *${reportName}*. Please try again later as I look into what went wrong.`,
      mrkdwn: true,
      mrkdwn_in: ['text'],
    };
    return postChatMessage(message)
      .catch((ex) => {
        log.error(ex);
      });
  }
};

Let’s break down what’s going on here. First, we initiate the actual report function with await reportFunc(); this corresponds to the getUserActivity function we made earlier. This will generate the report and save it in the reportFilePath we created in the generateReportFunction.

Next, we make use of a delay hack; this is the delay utility function we created earlier. We need this hack as the file system module behaves inconsistently. The report file would get created but on the next call where we check if the file exists, it would resolve to false. Adding a slight delay between file system calls will create a buffer to guarantee that the file system is indeed done processing the file.

I’m sure there’s something I’m missing here so if anyone knows anything, please comment or better yet create a PR.

After the report function is done, we check to make sure the report actually exists and if it doesn’t we assume there’s no data for the report and respond to the user. To send this response we use a postChatMessage function that we haven’t created yet. We’ll do this shortly.

If the report exists we upload it to Slack using an uploadFile function that we’ll create soon. One interesting thing to note here is that we upload the file to the fileUploadChannel in our Slack config. This has to do with how Slack handles file permissions. By uploading a file to a public channel, the file automatically becomes public within the workspace and can be shared to anyone within the same workspace. You’ll need to create a channel on your Slack workspace to handle file uploads if you are looking to share the uploads with more than one user.

After the file gets uploaded, we need to send the user a notification that their report is ready and a link to the actual file. The response object from the upload function contains a file url that we can use to reference the file we just uploaded. We compose the message to send back to the user using some Slack message formatting features and finally use the postChatMessage function to send the message.

We are almost at the finish line guys! Let’s create the postChatMessage and uploadFile functions. Inside the src/modules folder, we’ll create a folder called slack with an index.js file:

  1. mkdir src/modules/slack
  2. touch src/modules/slack/index.js

We’ll also be using the request module to make HTTP calls. Install it by running:

  1. npm i request

Now open src/modules/slack/index.js and copy the following into it:

import fs from 'fs';
import config from 'config';
import request from 'request';

const slackConfig = config.get('slack');

export const postChatMessage = message => new Promise((resolve, reject) => {
  const {
    responseUrl,
    channel = null,
    text = null,
    attachments = null,
    replaceOriginal = null,
  } = message;

  const payload = {
    response_type: 'in_channel',
  };

  if (channel !== null) payload.channel = channel;
  if (text !== null) payload.text = text;
  if (attachments !== null) payload.attachments = attachments;
  if (replaceOriginal !== null) payload.replace_original = replaceOriginal;

  request.post({
    url: responseUrl,
    body: payload,
    json: true,
  }, (err, response, body) => {
    if (err) {
      reject(err);
    } else if (response.statusCode !== 200) {
      reject(body);
    } else if (body.ok !== true) {
      const bodyString = JSON.stringify(body);
      reject(new Error(`Got non ok response while posting chat message. Body -> ${bodyString}`));
    } else {
      resolve(body);
    }
  });
});

export const uploadFile = options => new Promise((resolve, reject) => {
  const {
    filePath,
    fileTmpName,
    fileName,
    fileType,
    channels,
  } = options;

  const payload = {
    token: slackConfig.reporterBot.botToken,
    file: fs.createReadStream(filePath),
    channels,
    filetype: fileType,
    filename: fileTmpName,
    title: fileName,
  };

  request.post({
    url: slackConfig.fileUploadUrl,
    formData: payload,
    json: true,
  }, (err, response, body) => {
    if (err) {
      reject(err);
    } else if (response.statusCode !== 200) {
      reject(body);
    } else if (body.ok !== true) {
      const bodyString = JSON.stringify(body);
      reject(new Error(`Got non ok response while uploading file ${fileTmpName} Body -> ${bodyString}`));
    } else {
      resolve(body);
    }
  });
});

Let’s break down the postChatMessage function. We begin by initializing some optional parameters to null. There’s an interesting responseUrl we are using. We get this responseUrl from the object Slack posts to our webhook. Every message has a corresponding responseUrl that you can use to reply to it later. This works for us as we told the user we were generating the report and would notify them when we are done. We then construct the payload to send to Slack and finally fire off the request.

Next, we have the uploadFile function. Here we simply stream the file to Slack on the file upload URL that’s set in the slack config and use our botToken to authorize the request.

Now open src/modules/reports/index.js and add both the postChatMessage and uploadFile functions under the utils.js import:

import { postChatMessage, uploadFile } from '../slack';

Run it!

Time to run our bot. Open your package.json file and add these scripts:

{
  "name": "reporterbot",
  "version": "1.0.0",
  "main": "./dist/index.js",

  // add the scripts key to your package.json

  "scripts": {
    "dev": "NODE_ENV=development babel-node src/index.js",
    "build": "rm -rf ./dist/ && babel src --out-dir dist/ --copy-files",
    "prod": "NODE_ENV=production node dist/index.js",
    "lint": "eslint src"
  },
  "dependencies": {
    ...
  }
}

Let’s break down these scripts.

The dev script runs our process using babel-node. This is a handy tool to use while in development that will run our src files through babel as well as run the node process in one go. You should not use this while in a production environment as it is considerably slower.

The build script uses babel to convert everything in the src folder into a dist folder. Make sure to add this dist folder to your .gitgnore as it doesn’t make sense to push it up to git.

The prod script runs our process using the normal node binary. This however relies on the already built files. Notice that the node call points to the index.js file in the dist folder.

Finally, the lint script uses ESlint to check our src files for any linting errors. A better workflow would be integrating ESlint directly into your editor for faster feedback instead of having to manually run this command. Check out the ESlint integration docs for more info.

You can now run the dev command together with your ngrok secure tunnel and head on over to your Slack workspace to test things out. If everything checks out your reporterbot should be ready for use and is waiting to greet you with a warm smile!

Conclusion

Slack can be used for all types of interesting things, reporting is one of the more common use cases. Slack has great documentation so make sure to dig through them to see all the interesting features available to you. Hopefully, this tutorial has taught you a thing or two about building Slack applications and Node.js development in general. If you have any improvements or thoughts please drop me a comment.

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about us


About the authors
Default avatar
Calvin Karundu

author

Still looking for an answer?

Ask a questionSearch for more help

Was this helpful?
 
Leave a comment


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

Try DigitalOcean for free

Click below to sign up and get $200 of credit to try our products over 60 days!

Sign up

Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

Get our biweekly newsletter

Sign up for Infrastructure as a Newsletter.

Hollie's Hub for Good

Working on improving health and education, reducing inequality, and spurring economic growth? We'd like to help.

Become a contributor

Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.

Welcome to the developer cloud

DigitalOcean makes it simple to launch in the cloud and scale up as you grow — whether you're running one virtual machine or ten thousand.

Learn more
DigitalOcean Cloud Control Panel