SlackMood – Analyse your teams happiness via Slack Emoji usage

We had a hack day in the office a few weeks back, and I decided I wanted to build something with Slack. Hack days give us a chance to work with people outside of our product teams, work with different and new technologies, as well as trying out fun ideas we’ve had.

Like any sensible company, we use Slack to help us collaborate and improve communication, but we also use it to share cat gifs (we have an entire channel) and a whole host of default, aliased and custom emojis. Based on this, I wondered if I could use our emoji use to gauge the average mood of the whole company. And so SlackMood was born.

emoji_stats

SlackMood showing that 85% of our current Slack use is neutral or positive.

My first step was figuring out how to get a feed of messages across our whole Slack. I’d already decided to build it in Golang, and fortunately some clever person had already built a Golang library for Slack, saving me a huge amount of work. I registered a new bot on the Slack developer site and started hacking.

Unfortunately I quickly ran into an issue. I wanted to get the RTM (real-time message) feed of every channel, but it turns out bot accounts can’t join channels unless they’re invited. I could see 3 solutions to this:

  1. Create a real Slack user with an API key (I decided Finance wouldn’t be happy with this)
  2. Add my own API key alongside the bot, use the API to have me join all the channels, invite the bot and leave – annoying everyone in the company
  3. Use the message history APIs to periodically scrape the channels.

I decided to go with 3, as it seemed the simplest to implement.

The actual code for this was relatively simple:

for _,c := range channels{
  if c.IsArchived{
    continue
  }
  hp := api.NewHistoryParameters()
  hp.Count = 1000
  h, err := s.Api.GetChannelHistory(c.ID, hp)

  if err != nil {
    log.WithFields(log.Fields{
      "error": err,
      "channelId": c.ID,
      "channel": c,
    }).Warning("Could not fetch channel history")
  } else {
    models.ParseEmoji(h.Messages)

    log.WithFields(log.Fields{
      "channel": c.Name,
      "channelId": c.ID,
      "messages": len(h.Messages),
    }).Debug("Got channel history")
  }
}

It then passes the message object into a function that extracts the emoji counts.

func ParseEmoji(messages []api.Message){
  r := regexp.MustCompile(`:([a-z0-9_\+\-]+):`)

  for _,m := range messages{
    msgId := fmt.Sprintf("%s-%s-%s", m.Timestamp, m.Channel, m.User)
    for _,r := range m.Reactions{
      emojiList.AddEmoji(r.Name, m, fmt.Sprintf("%s-%s-%s", msgId, m.User, m.Name))
    }

    foundEmoji := r.FindAllStringSubmatch(m.Text, -1)
    for _,em := range foundEmoji{
      emojiList.AddEmoji(em[1], m, msgId)
    }
  }
}

It uses both a regular expression on the message, and iterating over the reactions.

I’d decided to use BoltDB for the backend storage, maybe not the best idea as I think a relational datastore like Sqlite would have been much better suited, but Bolt was a technology I’d never used before so it seemed interesting. We generate a message ID from the base message, then the reactions all have their own IDs based on the user who posted them. These are all stored in BoltDB as message ID -> details, where details is a struct describing the emoji:

type Emoji struct{
  Name      string
  SeenAt    time.Time
  Channel   string
  User      string
}

Now we’ve got a list of emojis and their timestamps, we can go through and assign each one a rating, of ether positive, negative or neutral. Fortunately, some of our team had already built a spreadsheet of emoji sentiment analysis for a previous hack project (turns out, we love emojis) with positive to negative rankings (1 to -1):

Screen Shot 2016-07-04 at 14.59.59

Our emoji rankings spreadsheet, obviously.

With our emoji ranks loaded into a struct array, we can go through and analyse the score of our current listed emoji.

func GetMood(emoji []*Emoji) Mood{
  m := Mood{}

  for _, e := range emoji{
    for _,r := range ranks.EmojiRanks{
      if r.Name == e.Name{
        switch r.Rank {
        case 1:
          m.PositiveCount += 1
        case 0:
          m.NeutralCount += 1
        case -1:
          m.NegativeCount += 1
        }
        m.TotalCount += 1
        break
      }
    }
  }

  m.Positive = percentage(m.PositiveCount, m.TotalCount)
  m.Negative = percentage(m.NegativeCount, m.TotalCount)
  m.Neutral = percentage(m.NeutralCount, m.TotalCount)

  return m
}

(N.B. looking back at this now, I realise a map of emojiname -> mood would have been much better rather than a double-loop, but this was like 6 hours in and I was keen to get something working).

Now we know the mood of all the emojis, calculating the graph just involves iterating through all the seen emojis and storing them in a map of date->mood. The GetMood function above works on a list of emojis, so we just bucket the emojis by the selected time period.

Due to storing all the emoji in Bolt and not being able to do proper filtering, we first filter by the time period we care about, then divide this up.

type Mood struct{
  Positive      float32
  Negative      float32
  Neutral       float32
  PositiveCount int32
  NegativeCount int32
  NeutralCount  int32
  TotalCount    int32
  Time          time.Time
  TimeString    string
}

func FilterEmoji(from time.Time, to time.Time, emoji []*Emoji) []*Emoji{
  var emj []*Emoji
  for _, e := range emoji{
    if e.SeenAt.After(from) && e.SeenAt.Before(to){
      emj = append(emj, e)
    }
  }

  return emj
}

func GraphMood(over time.Duration, interval time.Duration) []Mood{
  var points []Mood

  now := time.Now().UTC()
  dataPointCount := int(over.Seconds()/interval.Seconds())
  endTime := time.Unix(int64(interval.Seconds())*int64(now.Unix()/int64(interval.Seconds())), 0)
  periodEmoji := FilterEmoji(endTime.Add(over*-1), endTime, AllEmoji())
  for i:=0;i<dataPointCount;i++{
    offset := int(interval.Seconds())*(dataPointCount-i)
    startTime := endTime.Add(time.Second*time.Duration(offset)*-1)

    m := GetMood(FilterEmoji(startTime, startTime.Add(interval), periodEmoji))
    m.Time = startTime
    m.TimeString = startTime.Format("Jan _2")
    points = append(points, m)
  }

  return points
}

GraphMood returns a struct array which we can just JSON encode and feed into Chart.JS to get the nice visualisation above.

All in all, it was pretty fun but the whole project contains a lot of terrible code. If you want, check it out on Github here.

Other stuff I would have liked to add:

  • Most positive/negative person
  • Most used emoji
  • Biggest winker ?

Maybe next hack day.


P.S. if you fancy working somewhere with regular hack days, in a team which has a pre-prepared spreadsheet with emoji sentiment analysis, Songkick are hiring a variety of technology roles at the moment. So come work with us, we have a 64% SlackMood happiness rating™.