Smack: Mic’s Slack Bot for Live SMS Coverage

Roilan Salinas
Mic Product Blog
Published in
7 min readJul 28, 2016

A few weeks ago, the Mic Product team were testing the idea of sending our users a series of SMS messages as a part of a story. We wanted to see what kind of user engagement we would see and if users were even interested in having messages sent directly to their phone.

Our hypothesis was that in a chat UI, users are easily put off by copy that seems automated. Users don’t want to feel “spammed” or think a robot is talking to them. They want to have a real human conversation. So we set out to build a tool that would allow Mic editors to quickly compose, send, and receive SMS messages. “Smack” (SMS + Slack) was the result.

(Heads up: this piece is a primarily a technical product walkthrough. For a product and strategy-centric overview, check out this VentureBeat piece.)

Real photo of a conversation with a real user

Our bot needed a home that our team was familiar with and could use quickly. We chose to use Slack.

Slack provides great integrations and their real time messaging API. Since it’s a WebStocket-based API it’s nearly instant. Pairing it with Twilio for SMS and the Slackbots module made it seamless and fast to build our bot.

Here’s where we started:

import SlackBot from ‘slackbots’;
const bot = new SlackBot({ token: ‘’, name: ‘Le Bot’ });

bot.on(‘start’, () => {
bot.postMessageToChannel(‘testing-bot’, ‘hellooo world!’);
});

bot.on(‘message’, (data) => {
console.log(data);
});

Any incoming messages (text, status, etc.) are sent as data. A “gotcha” here is that you need to invite your bot to a certain channel so that it can start listening to those messages. You also need to filter the types of message you want to trigger an action by the bot. In our case, we only cared about the text type. That handler looked like this:

bot.on('message', (data) => {
const { type } = data;

if (type === 'text') {
console.log(data)
}
});

While handling a variety of incoming message types, our bot needs to temporarily store its todos, also known as state. I gave it a small object similar to this:

const initialCommandObj = {
active: false,
action: '',
command: '',
phoneNumber: '',
text: '',
sent: false,
userId: '',
username: ''
};

let currentCommand = Object.assign({}, initialCommandObj);

This was okay for the time being, but I quickly realized that as our bot’s user base grows, it needs better state management. Simply doing currentCommand.action = “foo” won’t cut it.

Rolling my own version of Redux was what I first thought of. My current state was simple. All I had to was create a pure function to return my new object. But this would create additional workload and a new point of failure even when Redux does exactly what I want and works.

Our Redux module now cleans everything up and looks like:

export const PENDING_MESSAGE = 'PENDING_MESSAGE';
export const PENDING_CONFIRMATION = 'PENDING_CONFIRMATION';
const RESET_USER = 'RESET_USER';
const SET_USER = 'SET_USER';
const SET_TEXT = 'SET_TEXT';

const INITIAL_STATE = {
active: false,
action: '',
command: '',
phoneNumber: '',
text: '',
sent: false,
userId: '',
username: ''
};

export default function reducer(state = INITIAL_STATE, action) {
switch (action.type) {
case SET_USER:
return Object.assign({}, state, action.user, {
action: PENDING_MESSAGE
});

case PENDING_CONFIRMATION:
return Object.assign({}, state, {
action: PENDING_CONFIRMATION
});

case PENDING_MESSAGE:
return Object.assign({}, state, {
action: PENDING_MESSAGE
});

case RESET_USER:
return Object.assign({}, INITIAL_STATE);

case SET_TEXT:
return Object.assign({}, state, {
text: action.text
});

default:
return state;
}
}

export const setUser = (user) => ({
type: SET_USER,
user
});

export const setPendingConfirmation = () => ({
type: PENDING_CONFIRMATION
});

export const setPendingMessage = () => ({
type: PENDING_MESSAGE
});

export const resetUser = () => ({
type: RESET_USER
});

export const setText = (text) => ({
type: SET_TEXT,
text
});

This cleaned up a lot of the concern of what our next action is for the bot and made it all easier to manage. Since now we know exactly what our actions should every step of the way, we can specify them for the bots incoming message:

// utils
export const blast = 'sms-blast';
export const dm = 'sms-dm';

export function parseNumber (text) {
const split = text.split(' ');

if (split.length !== 2) {
return false;
}

return Number(split[1]);
}

export function acceptedCommands (text) {
if (!text) {
return false;
}

const accepted = [blast, dm];
const command = text.split(' ')[0];
const filtered = accepted.filter(accept => accept === command);

return filtered.length > 0 ? filtered[0] : false;
}
// index
import { createStore } from 'redux';
import reducer from './module';
import { blast, dm, parseNumber, acceptedCommands } from './utils';

let store = createStore(reducer);
let state = store.getState();

store.subscribe(() => {
state = store.getState();
});

function postToChannel(message) {
return bot.postMessageToChannel(channel, message, params);
}

function getUsername(id) {
return bot.getUsers()
.then(({ members }) => members.filter(user => user.id === id))
.then(user => user[0]);
}

bot.on('message', (data) => {
const { type } = data;
const command = acceptedCommands(text);

if (type !== 'text' || !command) {
return;
}

if (!state.active) {
getUsername(user)
.then(({ id, name }) => {
let title;
let userObj = {
username: name,
userId: id,
active: true,
command
};

if (command === dm) {
const parsedNumber = parseNumber(text);

if (!parsedNumber || isNaN(parsedNumber)) {
postToChannel(`Uh no, you might have forgot to enter a number or provided it in an invalid format. It should look like this:`)
.then(() => postToChannel('`' + dm + ' 1231231234`'))
return;
}

userObj.phoneNumber = parsedNumber;
title = 'SMS-DM';
} else if (command === blast) {
title = 'SMS-Blast';
}

store.dispatch(setUser(userObj));
postToChannel(`${title} - What's your SMS message ${state.username}?`);
});
} else if (state.active) {
postToChannel(`Can't process anymore commands until the current one is completed by ${state.username}`);
return;
}
});

In our initial message, we want to make sure there are no active users currently using the bot. We don’t want to end in a situation where multiple users can tell the bot what to do. It should only ever have a single user / owner at a time.

We also start by checking the command that a Mic editor wants to use. In our case, available commands are sms-blast and sms-dm. These allow the Mic editor to blast all active subscribers or direct-message a single user.

Actually sending a message is the next step—or set of steps. With this part, we want to ensure safety and avoid sending messages with typos, for example.

When an editor begins the multi-step process of composing and sending a message, the bot she interacts with should take instructions only from her. Another editor should not be able to hijack her SMS composition process.

// index
bot.on('message', (message) => {
const { user, type } = data;
const command = acceptedCommands(text);

// ... initial message

if (state.active && state.userId === user) {
// handle pending

// handle confirmation
}
}

Then we allow our editor to copy edit the message she just typed. Since we all use Slack casually all day, there’s high potential for submitting a typo.

The bot offers the editor the chance to review the message that is about to be sent. The editor must confirm the message by replying “yes.”

Sign off blast from RNC coverage

Here is what all of the above looks like in code:

// index
const numbers = []; // ideally pulled in from a database

bot.on('message', (message) => {
const { user, type } = data;
const command = acceptedCommands(text);

// ... initial message

if (state.active && state.userId === user) {
if (state.action === PENDING_MESSAGE) {
store.dispatch(setPendingConfirmation());
postToChannel(`Thanks ${state.username}. Does this copy look correct to you? Respond with \`yes\` or \`no\`.`)
.then(() => {
store.dispatch(setText(text));
postToChannel(`\`${text}\``);
});
}

// handle confirmation
if (state.action === PENDING_CONFIRMATION) {
if (text === 'yes') {
postToChannel(`Thanks ${state.username}. Sending away...`)
.then(() => {
if (state.command === dm) {
return sendTextMessage(state.phoneNumber, state.text);
} else if (state.command === blast) {
return Promise.all(numbers.map(number => delaySendTextMessage(number, state.text, 100)));
}
})
.then((data) => {
let msg;

if (state.command === dm) {
const { error, message, number } = data;

if (error) {
msg = `Failed to send to ${number}`;
} else {
msg = `Sent to ${number}`;
}
} else if (state.command === blast) {
const errors = data.filter(d => d.error);
msg = `Sent a blasted message to ${data.length - errors.length} total users with ${errors.length} failures.`;
}

store.dispatch(resetUser());
postToChannel(msg);
});
} else if (text === 'no') {
store.dispatch(setPendingMessage());
postToChannel(`What's your new SMS message ${state.username}?`);
}
}
}
}
// twilio
import twilio from 'twilio';

const client = twilio(sid, token);

export function delaySendTextMessage(number, text, delay) {
return new Promise(resolve => {
setTimeout(() => resolve(), delay);
}).then(() => sendTextMessage(number, text));
}

export function sendTextMessage(number, text) {
const clientObj = {
to: number.toString(),
from: config.phoneNumber,
body: text
};

return new Promise(resolve => {
client.messages.create(clientObj, (error, message) => {
resolve({ error, message, number });
});
});
}

And then there’s handling of send type:

  • With sms-dm, we only need to send a single message to one user, so we can just call our Twilio sendTextMessage promise.
  • With sms-blast, we need to delay our send text between 100ms due to the carrier limitation on long codes (we’re switching soon). Using Promise.all ensures that all the numbers in the array are resolved before we get to the next step.

Once the message is successfully sent, our state is “completed” and we reset for the next set of commands.

Since we’ve learned that users want specific messages. The next round of testing will include segments, MMS and how we define what kind of messages certain users want to see:

Testing a blast segment with MMS

To see the service in action, text ELECTION to 316–854–1629.

For more product updates from Mic, follow us on twitter.

--

--