Make Slack app respond to reacji.

November 9, 2019. Filed under python 56 slack 6 api 3

This post continues the series on creating a Slack app in Python, picking up after adding an App Home view. A lot of the subtle, emergent communication patterns within Slack happen by reacting to messages with emoji, and I thought it would be fun to take advantage of that playfulness within the app we're building.


post starts at commit 4120, and ends at commit 08eb


Slack app in Python series

  1. Creating a Slack App in Python on GCP
  2. Adding App Home to Slack app in Python
  3. Make Slack app respond to reacji
  4. Using Cloud Firestore to power a Slack app
  5. Distributing your Slack application

Reacji and custom emoji

Slack's emoji reactions or reacji are a common way for folks to interact with messages in a channel.

Example of using Slack reaction emoji aka reacji

For the application we're building, I was thinking it might be neat to add :ididit: and :udidit: emoji, which folks could use to add items they did to their list of accomplishments as well as use to add accomplishments to other folks' lists. I've noticed some folks discount or dislike tracking their own accomplishments, so this could be a playful way to get their team and community to help.

Add those emoji via the Customize Slack option in the top-left menu within your Slack workspace.

Go to Customize Slack in top-left nav menu

From there click on Add custom emoji and create an image somehow. I used Omnigraffle because I already had it open, but yeah, probably you'll use something else.

Add udidit emoji to Slack

Add your image, name it :udidit: and click Save. Then go ahead and do the same for the :ididit: emoji as well.

Show both ididit and udidit emoji in Slack

Now we have our custom emoji, and we just need to figure out how to get notified when they're used.

Subscribing to reaction_added events

Whenever an emoji is added, an associated reaction_added event can be fired to the Events API, if you've subscribed to it.

To subscribe, head over to Event Subscriptions in your App dashboard, open up Subscribe to workspace events and select reaction_added.

Adding reaction_added event to Event Subscriptions

Remember to click Save Changes below, and reinstall your application with those additional permissions.

Handling reaction_added events

Now that we're receiving these events, we need to extend event_callback_event to handle reaction_added events rather than erroring on them.

def event_callback_event(request, parsed):
    event_type = parsed['event']['type']
    if event_type == 'app_home_opened':
        return app_home_opened_event(request, parsed)
    elif event_type == 'reaction_added':
        return reaction_added_event(request, parsed)
    else:
        raise Exception("unable to handle event_callback event type: %s" % \
                        (event_type,))

For our first implementation of reaction_added_event, let's just log the incoming message so we can get a look at its parameters.

def reaction_added_event(request, parsed):
    print(parsed)
    return "Ok"

I added :relaxed: to a message and the handler emitted this message from Slack.

{
  'token': 'ttttttttttttt',
  'team_id': 'tiiiiiiiiiiii',
  'api_app_id': 'aaaaaaaa',
  'event': {
    'type': 'reaction_added',
    'user': 'uuuuuuuuu', 
    'item': {
      'type': 'message',
      'channel': 'ccccccccc',
      'ts': '1573231294.000900'
    },
    'reaction': 'relaxed',
    'item_user': 'iuuuuuuuu',
    'event_ts': '1573250406.000200'
  },
  'type': 'event_callback',
  'event_id': 'eeeeeeeee',
  'event_time': 1573250406,
  'authed_users': ['uuuuuuuuuu']
}

The first thing we want to do is to filter down to reactions we're interested in. We only want to act on the two reactions we added, :ididit: and :udidit:, and we only want to handle reactions to message items, ignoring files and what not.

def reaction_added_event(request, parsed):
    event = parsed['event']
    if event['reaction'] in ('ididit', 'udidit'):
        if event['item']['type'] == 'message':
            print(parsed)
            print("yes, handling this message")

    return "Ok"

Now that we've filtered down to appropriate messages, we're still not really sure about the contents of the message: what did you or they actually do? To answer that, we'll need to make a call to conversations.history as described in the Retrieving messages docs.

Adding channels:history scope

Before we can call the channels.history endpoint, we first need to request the channels:history OAuth scope. Go into your app dashboard, click on OAuth & Permissions, scroll down until you see Scopes and then add channels:history.

Request channels:history scope for OAuth tokens

After that, reinstall your app to request the additional OAuth scope. We've been using the bot token so far, not the OAuth token, so we'll also need to the OAuth token to our env.yaml file. Your OAuth token is in your App admin under OAuth & Permissions in the field labeled OAuth Access Token.

SLACK_SIGN_SECRET: your-secret
SLACK_BOT_TOKEN: your-bot-token
SLACK_OAUTH_TOKEN: your-oauth-token

With that set, we can take advantage of our new scope.

Calling channels.history

We previously implemented the slack_api utility function to simplify calling the Slack API, but it turns out that we can't reuse it easily for two reasons. First, conversations.history wants application/x-www-form-urlencoded requests whereas the other endpoint accepted application/json. Second, this endpoint wants a GET instead of a POST, although that's fixed easily enough by using the requests.requests function which accepts the HTTP method as a string.

To work around those constraints quickly, we'll write a get_message function which will call into the API directly, instead of building on slack_api, even though that's a bit on the sad side.

def get_message(channel, msg_ts):
    url = "https://slack.com/api/conversations.history"
    bot_token = os.environ['SLACK_OAUTH_TOKEN'].encode('utf-8')
    params = {
        'token': bot_token,
        'channel': channel,
        'latest': msg_ts,
        'limit': 1,
        'inclusive': True
    }
    resp = requests.get(url, params=params)
    return resp.json()

Then we'll update reaction_added_event to use this new function.

def reaction_added_event(request, parsed):
    print(parsed)
    event = parsed['event']
    if event['reaction'] in ('ididit', 'udidit'):
        item = event['item']
        if item['type'] == 'message':
            print("yes, handling this message")
            channel = item['channel']
            msg_ts = item['ts']
            msg = get_message(channel, msg_ts)
            print(msg)
    return "Ok"

Deploy the event_post function, add :ididit: to a message, and we can get a look at the response format for conversations.history.

{
  'ok': True,
  'latest': '1573231294.000900',
  'messages': [
    {
      'client_msg_id': 'cmiiiiiiiiiiiiiiiii',
      'type': 'message',
      'text': "I've finished upgrading the hosts!",
      'user': 'uuuuuuu',
      'ts': '1573231294.000900',
      'team': 'ttttttttt',
      'blocks': ["a lot of stuff omitted"],
      'reactions': [
        {
          'name': 'ididit',
          'users': ['uuuuuuu'],
          'count': 1
        }
      ]
    }
  ]

What we really care about are messages/0/user and messages/0/text, which we'll be able to use to add this message either to the speaking or emojing user depending on whether it's an :ididthis: or udidthis respectively.

Pulling all together

Somewhat conspicuously, we still don't have a database to store all of this, and we'll solve that in the next post, not this one. For now we'll create an interface for storing this data, the reflect function.

def reflect(team_id, user_id, text):
    print("Reflected(%s, %s): %s" % (team_id, user_id, text))

Then we'll update reaction_added_event to call that function.

def reaction_added_event(request, parsed):
    event = parsed['event']
    if event['reaction'] in ('ididit', 'udidit'):
        item = event['item']
        event_user_id = event['user']
        if item['type'] == 'message':
            channel = item['channel']
            msg_ts = item['ts']
            msg_resp = get_message(channel, msg_ts)
            msg = msg_resp['messages'][0]
            msg_team_id, msg_user_id, text  = \
              msg['team'], msg['user'], msg['text']
            if event['reaction'] == 'ididit':
                reflect(msg_team_id, msg_user_id, text)
            elif event['reaction'] == 'udidit':
                reflect(msg_team_id, event_user_id, text)
    return "Ok"

Deploy again, and we've integrated reacji!

Reacji as interface

When I first started using Slack, I assumed reacji were a gimmick, but then I remember the lightbuld going off when I first saw folks organically start voting on a message using the :plus: emoji. No one had asked them to vote, it just started happening, and it's that organic freedom, constrained with the rigid constraints (they are just small images with a count next to them) that lead to so the novel usage patterns.

No Slack App ever needs to integrate with reacji, but I've seen a bunch of creative integrations that acknowledge the patterns that folks already have and then enhance those patterns with automated action.

Another great aspect of reacji as user interface is they are more discoverable than Slash Commands, which are usually hidden from other users. Organic growth and adoption are underpinning of a successful app, and reacji are a powerful mechanism to that end.

Integration friction

I'll say that I was a bit surprised at how long it took me to get reacji working, because I'd come into this post assuming I was already done with most of the necessary work and would just be introducing a new event.

Instead I needed to add a new style of API integration since conversations.history didn't support the JSON format, and a new API token since previously I'd been using the bot token rather than the user token. Individually, each of the Slack APIs are extremely well designed, it's only collectively that they start to surface some degree of friction.

This is a common challenge for broad, sophisticated APIs. I'm currently reading Building Evolutionary Architectures, which is better, more structured coverage of the ideas I wrote about in Reclaim unreasonable software. API deterioration can be prevented, but requires very deliberate usage of "asserted properties" in my post's nomenclature or "fitness functions" in Ford/Parsons/Kua's.

Next

We've now reached commit 08eb, and are down to two more goals: integrating a database, and publishing this into the applications directory. I'll work on the database next, as it's hard to publish an app that is exclusively stub data, and then we can complete the publishing step.


series continues in Using Cloud Firestore to power a Slack app