Building A Python Slackbot That Actually Works
If you’re a Slack user, you’re probably no stranger to the various integrations floating around your workspace — webhooks, reminders, slash commands, apps, alerts, you name it. But it’s less common to find well-functioning bots that do much beyond delivering weather reports or canned responses to narrow inputs.
In this post I will share an approach to designing a more all-purpose python Slack integration, with use cases that hopefully surpass sheer novelty and extend into the realm of everyday utility. My goal is less to provide a finished product (though the code for this project is available here) and more to provide a framework for designing flexible bot applications with modular, customizable functionality.
Creating a New App
Before you start coding, you’ll need to create an app for your workspace, which you can do via this page:
I won’t spend too much time going step-by-step through the various configurations that can be set here, but be sure that you:
- Add a bot user for the app
- Install the app
- Note the bot user OAuth access token (found in the “OAuth & Permissions” tab), since you’ll need this later
After adding the integration, you should see a new entry in the “Apps” section of your Slack workspace with the display name you chose for your bot.
Installing Dependencies
The python packages used will vary based on your bot’s features. I’ve included a requirements.txt file in the github repo for this demo (installed in a python 3.7 virtual environment), but with the exception of slackclient most of the names there are not strictly needed for a functioning bot. The slackclient library can be installed via pip or conda-forge.
Project Structure
Now that your app and the dependencies are installed, you’ll need a place to hold the code that will control it. For this generic template, I put my project in a new directory called “slackbot” with a simple one-dimensional layout:
The bot’s main processes will be run out of a single python file, which I’ve called slackbot.py in my example, but you could also name this file something like main.py or <the_name_of_your_bot.py>. This is where you’ll use the slackclient python package installed earlier to connect to Slack’s API and monitor the data flow in your workspace in real time.
In the same directory, add three other files:
- triggers.py for parsing and prioritizing commands
- response_map.py for mapping commands to responses
- responses.py for holding the bot’s actual response functions
More on each of these files later.
Running Your Bot
At the top of slackbot.py, we’ll add the following imports…
import os
import time from slackclient import SlackClient
…followed by a few global variables and a line instantiating the SlackClient class used to interact with Slack’s API:
# Bot settings & connection handlers
BOT_NAME = "<your_bot_name>"
slack_client = SlackClient(os.environ.get("SLACKBOT_TOKEN"))
I’ve set my personal SLACKBOT_TOKEN as an environment variable to avoid posting it here, but this value should correspond to the bot user OAuth access token you noted in the “OAuth & Permissions” tab earlier.
Next you need to define the main loop that will continuously poll your workspace for activity:
if __name__ == "__main__":
if slack_client.rtm_connect():
print(f"{BOT_NAME} now online.")
while True:
text_input, channel = parse_slack_output(slack_client.rtm_read())
if text_input and channel:
handle_command(text_input, channel)
time.sleep(1) # websocket read delay
else:
print("Connection failed.")
The above code runs an infinite loop that queries Slack’s real time messaging API for output, parses commands from the output, and then sends the commands off to be handled. The parse_slack_output
and handle_command
functions highlighted above still need to be defined and implemented, however.
Parsing Output From the Real Time Messaging API
Data from the real time messaging API is returned as a python dictionary object, which makes searching for metadata about the incoming messages pretty straightforward. Let’s look for objects with a ‘text’ key. These can be checked for commands directed at our bot.
def parse_slack_output(slack_rtm_output):
"""Parses output data from Slack message stream"""
# read data from slack channels
output_list = slack_rtm_output
if output_list and len(output_list) > 0:
for output in output_list:
if output and 'text' in output:
text = output['text']
# if bot name is mentioned, take text to the right of the mention as the command
if BOT_NAME in text.lower():
return text.lower().split(BOT_NAME)[1].strip().lower(), output['channel']
return None, None
How you want to establish the syntax for interacting with your bot is up to you — here we’re just taking any text that comes after the bot’s name and considering that a command to the bot. It’s also helpful to track the channel that the message came from (using the ‘channel’ key in the output dict), which is why this function returns a tuple instead of a single object.
Handling Commands
Now let’s look at one way we can take the raw text passed to the bot and determine which responses the bot should return. In order to help with this we’ll introduce the three other modules mentioned earlier, triggers.py, response_map.py, and responses.py.
triggers.py:
import re
match_triggers = (
(re.compile("(hey|hi|hello|howdy|greetings)"), "hello"),
(re.compile("define"), "define"),
(re.compile("quote"), "quote")
)
search_triggers = (
(re.compile("weather"), "weather"),
(re.compile("comic"), "comic"),
(re.compile("joke"), "joke"),
(re.compile("pics"), "pics"),
(re.compile("(search)|(look up)|(google)"), "search"),
(re.compile("synonyms"), "synonyms"),
(re.compile("(wordcount)|(word count)"), "wordcount"),
(re.compile("(play|video|youtube)"), "youtube"),
)
def get_response_key(command, regex_type='match'):
regex = re.match if regex_type == 'match' else re.search
lookup = match_triggers if regex_type == 'match' else search_triggers
for key, value in lookup:
if regex(key, command):
return value
return None
The above implementation allows some commands to be require exact matches (i.e. “slackbot define <word>”) and others simply to require keywords (i.e. “slackbot tell me a good joke”). Introducing regex means it should be possible to define even complex pattern matching for your commands, and you can also order the patterns to determine the priority of potential matches.
response_map.py:
import responses as rsp
response_dict = {
'comic': [
rsp.comic
],
'define': [
rsp.define
],
'hello': [
"Sup",
"hey guys",
"yo",
"what's the word",
"hi"
],
'joke': [
rsp.joke
],
'no_command': [
"I don't know what you want from me",
],
'pics': [
rsp.get_pic
],
'quote': [
rsp.get_quote
],
'search': [
rsp.search_words
],
'synonyms': [
rsp.synonyms
],
'weather': [
rsp.check_weather
],
'wordcount': [
rsp.word_count
],
'youtube': [
rsp.get_video
],
}
For details on how the example functions provided in responses.py are implemented you can reference the github repo for this sample bot, but the main idea is that you can provide a flexible lookup mechanism mapping commands to defined responses. An example response for, say, the ‘joke’ command above might look like this:
Putting It All Together
Finally, back in slackbot.py you can import the new modules and use them in a handle_command
function to look up the proper response mapping, fetch the response itself, and then post that response to the target channel via the “chat.postMessage” endpoint. In this example, we check for exact matches first before searching keywords. I also set some default behaviors and define distinct logic between simple string responses and callable responses (namely the functions in responses.py).
# add these imports to the top of slackbot.py
from response_map import response_dict
from triggers import get_response_key
def handle_command(command, channel):
"""Take cleaned command, look up response, and post to channel"""
# Check for exact matches first then check for contained keywords
response_key = get_response_key(command, regex_type='match') or get_response_key(command, regex_type='search')
# Default behavior if no response keys match the command
if not (response_key or command):
# If no command is given
response_key = 'no_command'
elif not response_key:
response_key = 'search'
command = 'search ' + command
response = random.choice(response_dict[response_key])
# If response is a function, call it with command as argument
if callable(response):
response = response(command)
slack_client.api_call("chat.postMessage", channel=channel, text=response, as_user=True)
Running Your Bot
Once all your command triggers and responses are defined above the main control loop in slackbot.py, you should be able to start the bot straight from the command line with:
$ python slackbot.py
If all goes well, a message will be logged to the console saying the bot is online, and the app in your Slack workspace should display a green icon next to its name indicating it’s online. From there, you’ll just need to invite the bot to any channel where you’d like for your app to listen for commands.
What Next?
Of course, for the app to persist past your shell session, you’ll need to host it somewhere more permanent, but for development purposes it’s useful to test a variety of interactions with the bot in a local environment.
For a more robust and secure app, you’ll also want to implement much more thorough logging and exception handling, and think more carefully about how your app processes raw text — from both a security standpoint and a functionality standpoint, it’s important to consider all the various ways that raw input strings can be abused. This is particularly true if your Slack workspace is open to the public.