Building a Chore Slack Bot

March 20, 2019

Anyone who knows me well can tell you that I've had a long-time fascination with bots. In 2007, I built the first iteration of Phergie, a PHP IRC bot that's still seeing modest usage 12 years later. More recently, my current job has me doing as much or more development in Node than in PHP. This coupled with a situation at home prompted me to try my hand at building a Slack bot using Node.

Background

While Slack may be targeted primarily at businesses, I've also used it for personal stuff. Prior to building the bot, I'd been considering getting my family to start using Slack as well, and a situation came up that presented a good opportunity: chores.

When my kids got old enough, I started having them do chores around the house. The particulars of this arrangement have gone through a few iterations over the years. The most recent one involved two major changes.

First, it established a codified schedule for when chores should be done, mostly to set expectations and mitigate the need to remind the kids to do them. Since they aren't always great at keeping an eye on the time or remembering to set alarms, this change introduced a need for something to remind them when it was time to do chores and which chores should be done at any given time.

Second, where the kids previously had designated chore assignments that rotated weekly, they were now being granted a choice of which chores they would do in any given instance, in part to allow them some flexibility for schoolwork, illness, etc. This introduced a need to track which kid did which chore so they could be compensated appropriately.

At first, we had to manually enter and track all of this information in Google Sheets, which was very tedious and time-consuming. I wanted something more automated.

Requirements

Ultimately, I wanted this bot to do three things.

  1. Using a configured schedule, send chore reminders to a Slack channel for which the kids would receive notifications on their devices.
  2. Harvest data regarding who did what chore and when from the Slack channel.
  3. Report on demand how much each kid was owed for the chores they'd done since they were last paid.

Scheduling

For the schedule, I decided to use the RRULE standard, which has fairly wide support and would easily support announcing chores daily, weekly, biweekly, monthly, or whatever other reasonable frequency I wanted. In particular, I chose the rrule.js library.

Here's a portion of the chore schedule in my bot's configuration.

const { RRule } = require(`rrule`);

const weekdays = [ RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR ];
const weekends = [ RRule.SA, RRule.SU ];

const schedule = [

    {
        rrule: {
            freq: RRule.DAILY,
            byweekday: weekdays,
        },
        times: [ `06:00`, `16:00`, `18:00`, `20:00` ],
        description: `Take dogs out`,
    },

    {
        rrule: {
            freq: RRule.DAILY,
            byweekday: weekends,
        },
        times: [ `08:00`, `10:00`, `12:00`, `14:00`, `16:00`, `18:00`, `20:00` ],
        description: `Take dogs out`,
    },

    // ...

];

While this data might have been put into a database in a traditional application, since it doesn't change very often and it's generally me who's changing it anyway, a flat file seemed like a better medium for my use case.

One thing that tripped me up for a bit had to with timezones, because timezones are hard. rrule.js generally encourages just using UTC; this works, but only up to a point.

Times relative to my local timezone, as in the above configuration file, had to be converted to UTC prior to being used with rrule.js. The Luxon library, which rrule.js uses internally, thankfully made this pretty easy to do.

const { DateTime } = require(`luxon`);

convertLocalTimeToUTC([hour, minute]) {
    const converted = DateTime.local().set({ hour, minute }).toUTC();
	return [ converted.hour, converted.minute ];
}

With this, it's easy to create a rule set per chore with its respective frequency and times...

const { RRule, RRuleSet } = require(`rrule`);

/**
 * @param {object} chore Single element from the schedule configuration setting
 * @param {Date} dtstart Date/time value representing now
 * @return {RRuleSet} RRULE rule set for the specified chore
 */
getChoreRuleSet(chore, dtstart) {
    return chore.times.reduce(
        (ruleSet, time) => {
            const [ byhour, byminute ] = this.convertLocalTimeToUTC(
                time.split(`:`).map(Number) // time is a string like `08:00`
            );
            const options = Object.assign(
                {},
                chore.rrule,
                {
                    count: 1,
                    dtstart,
                    byhour,
                    byminute,
                    bysecond: 0
                }
            );
            ruleSet.rrule(new RRule(options));
            return ruleSet;
        },
        new RRuleSet()
    );
}

... and then to get the next instance of a chore based on its configuration.

// now is a Date instance representing the current moment in time
const ruleSet = this.getChoreRuleSet(chore, now);
const nextInstance = ruleSet.after(now);
// nextInstance is a Date instance representing the next chore occurrence

Finally, the chat.postMessage method from the Node Slack SDK is used to actually send a list of the next chores to be performed to the Slack channel.

Harvesting Data

When announcing chores, the bot posts a message to the Slack channel for each individual chore. Each kid indicates which chores they will do by posting a thumbs up reaction on the messages corresponding to those chores.

Adult users are designated in a list of user identifiers in configuration. Once a chore is completed, the kid who did it has to inform an adult. That adult then verifies that the chore was done correctly and posts a thumbs up reaction on the corresponding chore message in the Slack channel.

If the adult finds that the chore wasn't done, or wasn't done correctly, they can post a thumbs down reaction instead, which penalizes the kid who indicated they did or would do it, or all the kids if the chore wasn't indicated to be done at all or if multiple kids indicated that they did it (for simplicity's sake, so I don't have to try to referee arguments over how the compensation should be divided between them).

In addition to being used to send chore announcements to Slack, the Node Slack SDK is also used for data harvesting. Specifically, these API methods are used.

  • conversations.list is used to get the channel identifier based on the channel name, which is then used to access messages from the channel.
  • channels.history is used to get the actual channel message data, including the reactions to each message.
  • users.info is used to get the names of the users who react to messages, where reactions are attributed using user identfiers in channel message data.

I wrote a class that takes in this data once it's been fetched and massages it into a list of JSON objects that's then stored in a file. Each time the bot announces chores, it also harvests the reactions posted since its last announcement and incorporates that data into the JSON file.

Reporting

Using the chore data stored in the JSON file and a list of compensations for each chore stored in configuration, computing a total owed to each kid is pretty straightforward.

The reporting was a bit more difficult to figure out. To make the data available on demand, I implemented a Slack slash command. Specifically, I used the Slack Events API client for Node to implement request signature verification. I couldn't find an example in documentation specific to this use case and had to figure it out piecemeal on my own.

The bits and pieces of the solution look something like this.

First is the entry point, which is a simple HTTP server.

const http = require(`http`);

class EventsServer {

	start() {
		const server = http.createServer(this.handleRequest.bind(this));
		server.listen(PORT_FROM_CONFIGURATION);
	}

	// ...

}

Next is the request handler, which starts by validating the request. It checks that the appropriate request method (i.e. POST) is being used and that the request headers needed for Slack request signature verification are present.

/**
 * @param {object} req Request object
 * @param {object} res Response object
 * @link https://nodejs.org/api/http.html#http_class_http_incomingmessage
 */
handleRequest(req, res) {
	if (req.method !== `POST`) {
		res.statusCode = 405;
		return res.end();
	}

	if (!req.headers[`x-slack-signature`] || !req.headers[`x-slack-request-timestamp`]) {
		res.statusCode = 400;
		return res.end();
	}

	// ...
}

Information for the slash command is contained within the request body. To verify that the request is coming from Slack, we first have to get the entire body in string form. For this, the class uses the raw-body library.

const getRawBody = require(`raw-body`);

// ...

handleRequest(req, res) {
	// ...

	getRawBody(req, (error, body) => {
		this.handleParsedBody(req, res, error, body);
	});
}

If an error occurs when attempting to aggregate the body into a string, the handler returns a corresponding response.

handleParsedBody(req, res, error, body) {
	if (error) {
		res.statusCode = 400;
		return res.end();
	}

	// ...
}

Once the class has the entire body in string form, it proceeds to verify that the request is coming from Slack.

const { verifyRequestSignature } = require(`@slack/events-api`);

// ...

handleParsedBody(req, res, error, body) {
	// ...

	const signature = {
		signingSecret: `SECRET_FROM_CONFIGURATION`,
		requestSignature: req.headers[`x-slack-signature`],
		requestTimestamp: req.headers[`x-slack-request-timestamp`],
		body,
	};
	if (!verifyRequestSignature(signature)) {
		res.statusCode = 400;
		return res.end();
	}

	// ...
}

Once the request is verified, the data from it has to be parsed into something usable. The body is URL-encoded, so the class uses the native querystring module to parse it.

const parseQueryString = require(`querystring`).parse;

handleParsedBody(req, res, error, body) {
	// ...

	const parsedBody = parseQueryString(body.toString());

	// ...
}

At this point, all the slash command data is accessible. The bot calculates the totals owed, then sends an immediate response for Slack to transmit back to the channel.

handleParsedBody(req, res, error, body) {
	// ...

	const payload = { response_type: `in_channel`, text: `...` };

	res.statusCode = 200;
	res.setHeader(`Content-Type`, `application/json`);
	res.write(JSON.stringify(payload));
	res.end();
}

Plumbing

There are a few additional libraries that provided useful internal plumbing for the bot.

The async-cache library is used to cache user data from Slack, which doesn't change very often, so as to avoid hitting Slack API rate limits.

const AsyncCache = require(`async-cache`);

class UserFetcher {

    constructor(/* ... */) {
        this.cache = new AsyncCache({
            load: this.fetchUserInfo.bind(this),
        });
    }

    async fetchUserInfo(user, callback) {
		// const client = ...

        const response = await client.users.info({ user });

        if (!response.ok) {
            callback(response.error);
        }

        callback(null, response.user);
    }

    async getUserInfo(user) {
        return new Promise((resolve, reject) => {
            return this.cache.get(user, (err, result) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(result);
                }
            });
        });
    }

}

Configuration for the bot is stored in a JS file. However, Node caches conventional file imports, and the bot is a long-running process. This means that changes to configuration wouldn't be detected without restarting the bot if conventional imports were used. To get around this, I used the import-fresh library.

const path = require(`path`);
const importFresh = require(`import-fresh`);

class ConfigurationLoader {

    constructor(file) {
        this.file = file;
    }

    getInstance() {
        return importFresh(path.resolve(this.file));
    }

}

Finally, to wire up dependencies within the application, I used the jimple library, which was inspired by the PHP Pimple library that I've used in the past. Below are some select snippets from my container configuration file.

const Jimple = require(`jimple`);
const container = new Jimple();

container.set(`Argv`, process.argv);

const ConfigurationLoader = require(`./configuration-loader`);
container.set(`ConfigurationLoader`, c => {
    return new ConfigurationLoader(c.get(`Argv`)[2]);
});

const SlackWebClientFactory = require(`./slack/web-client-factory`);
container.set(`SlackWebClientFactory`, c => {
    return new SlackWebClientFactory(c.get(`ConfigurationLoader`));
});

// ...

module.exports = container;

Testing

I used AVA for tests and nyc for code coverage reporting.

One additional library that was useful for testing the code for scheduling chore announcements was lolex.

Fin

For now, this is the extent of functionality I've added to the bot. We're still testing it and working the kinks out. Hopefully, you've enjoyed this recounting of its development. Thanks for reading!