Recently I discovered the awesomeness of Slash Webtasks, an incredibly easy way to build your own Slack integrations using Webtask. And while it truly is an awesome tool, it got me looking into working with Slack directly. I was rather surprised how simple it was to add an integration, especially when paired with a webtask, and I’d like to share a simple demo I built today.

The idea behind the demo is simple. You’ve set up a Slack organization for your company or product and you’re concerned about keeping track of the conversations going on there. On the flip side, you also don’t want to spend every second of the day monitoring chats. What if you could get a warning when things seem to be going bad? For example, maybe some new bugs have cropped up and you have a lot of angry users complaining in a channel.

We can use Slack’s APIs to accomplish this with the following steps:

  • Send every message to a serverless endpoint
  • Take the text and analyze the sentiment
  • Look at historical data and see if things are going poorly
  • If things are going bad, send an email to let the administrator know

To handle the serverless endpoint, I’ll use Webtask for obvious reasons. For the sentiment analysis, I’ll use the Text Analytics API from Microsoft’s Cognitive Services. Let’s get started!

Prereqs

In order to build this demo, you’ll need a few things. First off, you’ll need an account with Webtask. Head over to https://webtask.io/make to connect via your favorite social login mechanism. You don’t have to pay a dime. Webtask supports both a CLI and an online editor so you are free to use the one that makes the most sense for you. If you’ve never used Webtask before, you probably want to spend a few minutes in the docs.

Next, you’ll need a Slack account and an organization you can install apps too. Most likely you’re already using Slack but you may not have a place where you have administration rights. When I was testing Slash Webtasks, I created my own Slack organization (raymondcamden.slack.com, and nope, you’re not invited) and it was quick and painless.

Finally, you’ll need to get a key for Microsoft’s Text Analytics API. I’ve got good news/bad news here. First, you can get a free key to test the API. Woot! However, for some reason Microsoft limits your key to 7 days:

Keys from Microsoft for the API

That really bugs me. I believe that after the seven days are up you can simply request a new key. And certainly if you are a paying customer this isn’t a problem, but as a developer playing around and testing, it’s a bit annoying to have a time limit like this. As I said, I assume you can simply request new keys later on, but I don’t know for a fact. Complaints aside, you’ll see that the API is easy to use which is a definite plus. For now though, you want to make note of the key.

Creating the Slack App

To begin creating the Slack app, head over to https://api.slack.com, click on “Your Apps” in the upper right hand corner, and you’ll be dropped onto a page listing your current apps. To begin creating a new app, click the “Create New App” button. (I apologize if that’s pretty obvious.)

You’ll be prompted for the name of the app and the workspace:

For app name, let’s go with something simple, like WebtaskCognitiveTest. For the workspace, select one you have access to and then hit the “Create App” button.

On the next page you can specify various features of your app. For our demo, we care about “Event Subscriptions”:

Event Subscriptions

On the next page, you’ll first want to click the toggle to enable events. This will open a form with two parts we care about. The first is the request URL and the second is the events you wish to subscribe to. Let’s handle that first and then we’ll come back to the URL. Click to add an event and select message.channels. This subscribes to every public message sent in the organization.

Now turn your attention back to the URL setting. Notice that it says:

We'll send HTTP POST requests to this URL when events occur. As soon as you enter a URL, we'll send a request with a `challenge` parameter, and your endpoint must respond with the challenge value.

Basically this is saying that when Slack validates your URL, it is going to so by sending a special value that you have to echo back. That means our code will need a bit of special logic for this validation as well as the ‘general’ code for the app we’re building. To get this URL, let’s create a webtask.

Creating the Webtask

In other tab, head over to Webtask’s online editor. (Again, if you are already familiar with webtask and prefer the CLI, use that.) Create a new blank webtask and let’s craft code that will simply handle the validation aspect.

/**
* @param context {WebtaskContext}
*/
module.exports = function(context, cb) {
  if(context.body && context.body.challenge) {
    console.log('responding to challenge');
    return cb(null, {'challenge':context.body.challenge});
  }
  
  //respond withan empty object
  cb(null, {  });
};

Essentially - look in the body of the request for a value called challenge and if it exists, we echo it back. Note the rest of the function does nothing now. At the bottom of the editor, make note of the URL for your webtask and click the copy button next to it. Paste this URL back in the form on the Slack side and you should see a “Verified” checkmark:

Verified URL

The last thing you’ll do on this page is hit “Save Changes”. Click on “Basic Information” and then click “Install your app to your workspace”. This will open a new dialog where you grant permission for the app to run in your Slack organization.

Grant Permission

Go ahead and click “Authorize” and you’ll be returned back with a nice success message.

At this point, you’re done on the Slack side. You’ve installed the app which basically consisted of specifying what event you cared about (all messages) and what URL to hit (the webtask). Now to get serious.

Building the App

At this point, I highly suggest turning on the log viewer in the editor if you are using the web-based IDE. This will be really helpful as we look at what Slack is sending us and build the application. I’m going to build this out in three phases:

  • In this first phase, I’ll just get the raw text of the message that was posted in Slack.
  • In the second phase, I’ll make a call to the Text Analysis API to get the score back.
  • In the third phase, I’ll start saving these scores and checking to see if the average has gone too low.

Version One

The first version is rather easy:

/**
* @param context {WebtaskContext}
*/
module.exports = function(context, cb) {
  if(context.body && context.body.challenge) {
    console.log('responding to challenge');
    return cb(null, {'challenge':context.body.challenge});
  }

  let text = context.body.event.text;
  console.log('Going to anaylze:',text);
  
  //respond withan empty object
  cb(null, {  });
};

Slack will send data about the event in a value called event. That’s really handy. I discovered this by using a quick console.log(context.body) because I’m an Enterprise JavaScript developer. I then did a quick few tests and looked at the logs:

Real developers debug with logs

Note that the event object also contains the channel where the message was broadcast. My demo here is not going to care about the particular channel but note that you can, and probably should, filter to specific channels.

Woot! Easy part done!

Version the Second

In this part, I’m going to add in the call to the Text Analytics API. Specifically I’m using the Sentiment Analysis API. The docs (check the link I just shared) explain the basics. You pass in a set of documents that contain the language code and text and then get a nice response. The API supports multiple documents which means our code could wait till it has a good set of messages, but for now I’ll keep it simple - one message to one API call. Let’s look at the code and then I’ll explain how it’s working.

const rp = require('request-promise');

/**
* @param context {WebtaskContext}
*/
module.exports = async (context,cb) => {
  
  if(context.body && context.body.challenge) {
    console.log('responding to challenge');
    cb(null, {'challenge':context.body.challenge});
  }
  
  let text = context.body.event.text;
  console.log('Going to anaylze:',text);

  //respond withan empty object
  cb(null, {  });

  //but keep working - that's all good
  let result = await analyzeText(text, context.secrets.key);
  console.log('result was '+JSON.stringify(result));  
};

async function analyzeText(str,key) {

  let documents = { 'documents': [
      { 'id': '1', 'language': 'en', 'text': str }
  ]};

  const response = await rp({
    method:'post',
    url:'https://westcentralus.api.cognitive.microsoft.com/text/analytics/v2.0/sentiment',
    headers:{
      'Ocp-Apim-Subscription-Key':key
    },
    body:JSON.stringify(documents)
  });
  
  try {
    return Promise.resolve(JSON.parse(response).documents[0].score);  
  } catch(e) {
    return Promise.reject(e);
  }
  
}

Alright, so first you want to make note of that I’m using the new JavaScript hotness here, async and await. It wasn’t necessary, but I’ve been waiting for a long time to give it a shot and I’m pretty impressed with how it works. Again, it’s not necessary, just me trying to show off a bit.

Next - notice that after we call the webtask callback, we make a call to a new function, analyzeText. That may seem weird but basically I’m “answering” the initial request and the continuing on to do more work.

In this case, the analyzeText function handles calling the Sentiment API and passing in the text from the Slack message. Note that key in this case comes from a Webtask secret which is the preferred way to embed things like API keys.

Finally - I added two npm modules. First was request-promise, and when that didn’t work, I remembered that request-promise doesn’t ship with request anymore so I added that too. And yes - I forget that every darn time I use it.

And honestly that’s really it. I call the API and return the results. Note that I’m just returning the score to keep things easy. Now lets look at two tests. One will be very positive and one very negative.

Logs with scores

Note the scores. The API returns values from 0 to 1 with 1 being a positive sentiment and 0 being negative. You can clearly see that the scores do a good job of reflecting the spirit of what I said. And yes - peas are gross.

The Third Version

Ok, we’re almost done. For the final version, I’m going to add email support. Let’s look at the code:

const rp = require('request-promise');

const helper = require('sendgrid').mail;

const FROM = 'raymond.camden@auth0.com';
const TO = 'raymond.camden@auth0.com';
const SUBJ = 'Slack Mood Notification';
// threshold for how low is too low
const LOW_THRESHOLD = 0.4;

/**
* @param context {WebtaskContext}
*/
module.exports = async (context,cb) => {
  
  if(context.body && context.body.challenge) {
    console.log('responding to challenge');
    cb(null, {'challenge':context.body.challenge});
  }
  
  let text = context.body.event.text;
  console.log('Going to anaylze:',text);

  //respond withan empty object
  cb(null, {  });

  //but keep working - that's all good
  let result = await analyzeText(text, context.secrets.ta_key);
  console.log('result was '+JSON.stringify(result));  
  
  //ok, so let's add to storage
  context.storage.get((error, data) => {
    if(!data || !data.records) {
      data = {
        records:[]
      };
    }
    
    data.records.push({
      time:Date.now(),
      score:result
    });

    //now filter it
    data.records = data.records.filter(currentData);

    //store immediately 
    context.storage.set(data, {force:1}, (err) => {

      //console.log('data is now: '+JSON.stringify(data));
      
      //now get an avg
      let total = data.records.reduce((a,b) => { return {score:a.score+b.score}; }, {score:0});
      //console.log('total was '+JSON.stringify(total));
      let avg = total.score / data.records.length;
      console.log('avg score is '+avg+' covering '+data.records.length+ ' data items');
      
      if(avg < LOW_THRESHOLD) sendNotification(avg, context.secrets.sg_key);
    });
    
  });
};

async function analyzeText(str,key) {

  let documents = { 'documents': [
      { 'id': '1', 'language': 'en', 'text': str }
  ]};

  const response = await rp({
    method:'post',
    url:'https://westcentralus.api.cognitive.microsoft.com/text/analytics/v2.0/sentiment',
    headers:{
      'Ocp-Apim-Subscription-Key':key
    },
    body:JSON.stringify(documents)
  });
  
  try {
    return Promise.resolve(JSON.parse(response).documents[0].score);  
  } catch(e) {
    return Promise.reject(e);
  }
  
}

/*
I filter data such that only items in the last hour count
*/
function currentData(item) {
  let hours = Math.abs(Date.now() - item.time) / (60*60*1000);
  //console.log('difference in hours is '+hours);
  return hours <= 1;
}

function sendNotification(score, key) {

  let body = `
Warning, at ${new Date()} the sentiment in the Slack room has fallen below the minimum threshold. 

Minimum Threshold: ${LOW_THRESHOLD}
Current Sentiment: ${score}

Send in the kittens!
`;

  let to_email = new helper.Email(TO);
	let from_email = new helper.Email(FROM);
  let mailContent = new helper.Content('text/plain', body);
  let mail = new helper.Mail(from_email, SUBJ, to_email, mailContent);
	let sg = require('sendgrid')(key);

	var request = sg.emptyRequest({
		method: 'POST',
		path: '/v3/mail/send',
		body: mail.toJSON()
	});
        
	return new Promise((resolve, reject) => {
		sg.API(request, function(error, response) {
			if(error) {
				console.log(error.response.body);
				reject(error.response.body);
			} else {
				console.log('mail sent');
				resolve();
			}
		});
	});

}

If you start up top, you can see a few const statements where I’ve set some defaults. The first setup values for the email and the fourth one, LOW_THRESHOLD, is what determines when an email should be sent. Note that I added the sendgrid npm module to my task. Also note that I renamed my previous secret, key, to ta_key to be at least a big more descriptive. I also added sg_key for my Sendgrid key.

In order to persist data, I’m using Webtask’s Storage API. This is a built in feature of Webtask that makes it easy to persist reasonably small amounts of data. In my case, I’m storing an array of text that’s going to be trimmed down by a date filter later, so I feel pretty confident this will fit in just fine. (Obviously in a real app I’d want to test more, and possibly use both a date and size filter.)

I first fetch the data and then add my new analysis to an array of records. I then filter it (based on time) and store it. Note the use of {force:1}. This basically says to not care about conflicts. I’m ok with losing a bit of data here but again, in a production app you may not be.

Finally I get an average sentiment and if it’s too low, I fire off an email. Here’s an example of that email:

No kittens were harmed in the sending of this email

Wrap Up

I hope you found this example useful. Note that the Slack API does a lot more than simply respond to events. I could, for example, send a message to the Slack group and include a nice kitten picture to help calm things down. For more ideas, check the Slack app docs and let me know what you think in a comment below!