Geek Culture
Published in

Geek Culture

How I created a Slack bot for my student org

A step-by-step for using the Slack Events API and Google Sheets to code your own Slack bot

Photo by Stephen Phillips - Hostreviews.co.uk on Unsplash

I’m the vice president of technical development at a tech professional fraternity on my college campus. We host a lot of events, many of them targeted towards helping org members develop in both their professional and technical skills, and we expect to have a certain level of activity for our members.

We wanted to be able to track this better (still in a Google Sheet, so it’s easy to view), as well as have a streamlined process for ensuring everyone who attended a particular event was recorded. We also wanted members to be able to have a summary of their own involvement for the semester, encouraging members to be more proactive in attending the required number of events. Since we already had every member in Slack, it was only natural that we develop a Slack bot that could do all of the above.

I took pointers from Texas Product Engineering Organization, another tech organization on our campus, and the code for their attendance bot. While it didn’t have everything I needed, the code was a great way to point me in the right direction to get started, and I referenced a lot of their async Google Sheets code.

Setting up the Slack Events API

The Slack Events API and serverless functions are the easiest way to go about this. Slack can automatically send a POST request to a URL you specify for events that you subscribe to (for example, channel mentions or anytime a new user is added to the workspace). This is useful for us because we’d want to be notified anytime a user DMs the bot.

I also used GCP Cloud Functions, first tested locally using their functions-framework, a Python module that you can install to replicate Cloud Function’s functionality on your local machine, or any other server you choose. I used ngrok to expose my localhost version of the server to the internet, for testing purposes, before uploading my final code to GCP.

After following the instructions to create a new Slack app, go to the Event Subscriptions tab in the left sidebar to turn on event subscriptions. It’ll ask for a URL to verify.

Verifying your URL

For privacy purposes, so that Slack can ensure that you have direct control over the server to which it’s notifying events to, Slack has a process to verify the URL that you give it. Note that if you change the URL, you’ll have to go through the verification process again, so keep this in mind if you decide to test locally and then move to a production server.

To do so, as soon as you paste in the URL (in my case, I pasted in the ngrok URL), Slack will send a simple POST request to it containing a challenge value. Your job is to simply return the challenge value it provided in a timely manner. (They’re extremely flexible with the return type and format.) Here’s how I did it:

def parse_request(event):
r = event.get_data().decode("utf-8")
r = json.loads(r)
return f"HTTP 200 OK\nContent-type: application/x-www-form-urlencoded\nchallenge={r['challenge']}"

where parse_request is my entry point into the serverless function (i.e. what the server will call when a POST request is made to it). You only need to verify a URL once, so you can delete this code after Slack verifies it.

Now that you’ve verified ownership of the server, you should add the necessary event subscriptions. I used message.im, which tells Slack to send a POST request every time the bot is DMed with the message content. (Ensure the checkmark under App Home > Messages Tab is turned on to allow messages to be sent to the bot if this is the scope you are using.)

After you install your bot to a workspace you can test on (through your app dashboard), you can start coding your bot!

Optional: Using the Google Sheets API

The bot I created does a lot of reading and writing to a Google Sheet, since this was the best way admins can also manage the data in the app and quickly see member summaries. You’ll need an active Google Cloud Platform account, from where you can enable the Google Sheets API. At the top of the page, a banner will appear prompting you to create credentials; this is where it’ll walk you through creating a service account, since this is what we’ll use to access the Sheet itself. Once it’s been created, click on the email in the Service Accounts page, “Manage Keys,” then add a new JSON key. You’ll just need this key with most GCP SDKs, including the one we use (aiogoogle).

Don’t forget to share the sheet with the service account email (with Editor access) so that the service account can read and write to the sheet.

Developing the bot

In your parse_request function, I included some code that would check that the POST request actually came from Slack. Slack makes it really easy to do this: Slack sends you a hashed code along with the event details, and if you use HMAC (pip install hmac) to hash the request body using the secret bot token (which you can get from the app dashboard), the two codes should match. Here’s how I did it:

def verifySlackRequest(event):
headers = event.headers
request_body = event.get_data().decode("utf-8")
timestamp = headers.get('X-Slack-Request-Timestamp', None)
# if the request came from more than 5 mins ago, could be an attack
if timestamp is None or abs(time.time() - int(timestamp)) > 60 * 5:
return False
sig_basestring = 'v0:' + timestamp + ':' + str(request_body)
my_signature = 'v0=' + hmac.new(
slack_secrets.slack_signing_secret,
sig_basestring.encode(),
hashlib.sha256
).hexdigest()
slack_signature = headers['X-Slack-Signature']
return hmac.compare_digest(my_signature, slack_signature)

If this came out to be true, I sent the data into a router (in a concurrent process) that could figure out what to do with the message. Using the built-in multiprocessing library in Python, I could send a quick 200 response back to Slack before actually processing the event data. The response time must be under 3 seconds, or Slack will keep retrying (and eventually disable the Events API with too many unsuccessful returns) and with server spin up time, waiting for the rest of the code to finish running would have taken slightly longer than 3 seconds. Here’s what that looks like:

from multiprocessing import Process

def parse_request(event):
# verify that the request came from Slack
if not verifySlackRequest(event):
resp = flask.Response("Unauthorized")
return resp, 401

# route the request to the appropriate handler in a new thread
data = event.get_json()
user_id = data['event']['user']
process = Process(target=router, args=(data, user_id))
process.start()

return "Success", 200

Notice that we start a process so that we don’t have to wait for the code to finish running before returning a value, and GCP will take care of waiting just a little bit longer until our code fully executes before tearing down the server.

And here’s the router function.

# route the request to the appropriate gsheets handler
def router(data, user_id):
text = data['event']['text']
channel_id = data['event']['channel']
if 'register' in text.lower():
info = gsheets_handler.register_user_handler(user_id, text)
msg = create_message(info['header'], info['body'], error=True if "Error" in info['header'] else False)
send_message(msg, channel_id, user_id)
return {
'statusCode': 200,
'body': 'OK'
}
# more elifs here...
else:
send_message(help, channel_id, user_id)
return {
'statusCode': 200,
'body': 'OK'
}

Let’s take a look at the first if block.

Registering a user

I wanted to be able to take a note of the user’s unique Slack ID and note down their name and email in a Google Sheet of users. Here’s what the register_user_handler function does:

# register a user in the database
def register_user_handler(user_id, text):
info = text.split(" ")
if len(info) != 4:
return {
"body": "Please use the following format: `register Firstname Lastname email`",
"header": "Error registering user: Invalid format"
}
return asyncio.run(register_user(user_id, info[1], info[2], info[3]))

async def register_user(user_id, first_name, last_name, email):
async with Aiogoogle(service_account_creds=service_account_creds) as google:
sheets_api = await google.discover("sheets", "v4")
name_match = await find_all_column(google, sheets_api, 'Users', 'A', user_id)
if name_match:
return {
'body': "You are already registered in the attendance system. Please notify an admin if you think this is a mistake.",
'header': "Error: User already exists"
}
else:
email = email.split("|")[1][:-1]
await insert_row(google, sheets_api, 'Users', [user_id, first_name, last_name, email])
return {
'body': f"Registered {first_name} into attendance system. You can now check into events.",
'header': "Success"
}

First, the handler does some, well, error handling. In case the message wasn’t in the right format, the bot should tell the user. (Note that I’m only returning a simple JSON for all these functions, because the router takes care of actually crafting the message and sending a new Slack message from the JSON.)

It then calls a new asynchronous function. Slack requires requests to be responded to within 3 seconds, and the function being asynchronous takes off just enough time to send a response within the time limit. We used aoigoogle, a Python library that takes care of calling async functions to Google APIs.

We had a few helper functions: find_all, find_all_column, and insert_row, that take care of actually getting or posting the data to the Google Sheet database. You can see these functions in the full code later.

Since we’ve just written the code to generate the correct message, now we have to send it in the correct format. Here’s the create_message function, which just takes a JSON with a “header” and “body” (for formatting purposes only) and generates a formatted string.

def create_message(header="", body="", error=False):
slack_emojis = ["partying_face", "white_check_mark", "tada", "rocket", "money_mouth_face", "champagne", "confetti_ball", "guitar", "bulb", "ok", "checkered_flag", "smile"]
message = ""
if header != "":
message += f"*{header}*"
decorator = "bangbang" if error else random.choice(slack_emojis)
message += f" :{decorator}:\n\n"
message += body
return message

And here’s the function definition for send_message, which actually takes care of making a POST request to the Slack API.

def send_message(msg, cid, uid):
data = {
"Content-Type": "application/json",
"token": slack_secrets.slack_bot_token,
"channel": cid,
"user": uid,
"text": msg
}
r = requests.post("https://slack.com/api/chat.postEphemeral", data=data)
print(r.text)
return r

Note, to send a message, you need the channel ID, and to send an ephemeral message (only the chosen recipient can see the message; i.e. you’ll see the “Only visible to you” note above the message) you’ll also need the user ID you’d like to send the message to. The reason we want to send ephemeral messages in DMs is because we don’t want the Slack Events API to send another notification to our server every time the bot sends a message in the channel as well. You can find the channel ID in the original event object sent from Slack.

Lastly, functions-framework requires that the function always send some kind of return back. I always return a simple “Success” with status code 200, since the actual return doesn’t matter to Slack (just the status code, which must be 2xx, or Slack will try again).

To test, DM the bot, “register <first_name> <last_name> <email>”.

What the test data looks like in the ‘Users’ sheet. The ‘Full Name’ column is generated via the CONCAT Google Sheet formula on the first and last names.

Other features

Here are all the features I chose to incorporate:

newevent: admins (specified by a single list of user IDs in an “Admins” sheet on the same document) can create new events by specifying a title and type (out of four given types) and receiving a unique code back (generated from the first few characters in a UUID object)

The “Events” tab in the Google Sheet. Each time an admin creates a new event, they receive a message with the unique code and the code is placed here.

checkin: users can check in with the unique event code given by the event organizer, and this will log their attendance for that event

The “Attendance” tab in the Google Sheet. If a user is not already registered for an event, they can register for an event, and the time and event information is logged here along with their User ID. (The “Name” information comes from a VLOOKUP function on the “Users” tab, so that another call to the Sheets API does not have to be made.)

updateme: if registered, users can see a summary of the number of events they’ve been to by type, out of the total number of events hosted

The “Summary” tab, entirely generated by formulas. USER ID comes from IMPORTRANGE on the “Users” tab (and Name from VLOOKUP, as usual), while the rest of the columns are COUNTIFS on the “Attendance” tab by user and type of event. This doubles as a simple dashboard overview for admins to check member activity.
The user summary from the bot.

eventstatus: admins can specify an event code and receive the list of users who have checked in to their event

The event summary from the bot with the command “eventstatus <eventcode>.”

You can see the full code here!

Hosting your code on Cloud Functions

In your GCP account, look up cloud functions under Products and follow the instructions to activate. Once this is done (and you’ve checked Allow all traffic; you can also find this under Edit Function > Runtime, build, connections and security settings > Connections) you need to add one more permission so that it’s accessible to the public. Go to Permissions and add a new permission by clicking Grant access. Type in “allUsers” under Add principals with the role “Cloud Functions Invoker.”

Now you can edit the source code (through the inline editor or by uploading a .zip) to include all your information. Cloud Functions also allows you to keep secrets (through Edit Function), so if you’d like to keep your secrets such as the bot token or the sheet ID, you can go there.

Note that Cloud Functions also generates its own “service account” when you use it, so you can also use the native Cloud application account to access the Google Sheets API as an alternative to the service account and service account credentials.

Once the function has been deployed, remember to reverify it with the Slack Events API (you may have to deploy again to switch your code from verification to actually parsing an Events request). That’s it!

The help message displayed by the bot anytime a user gives an invalid or unrecognized command.

Neha Desaraju is a student at the University of Texas at Austin studying computer science. You can find her online at estaudere.github.io.

--

--

A new tech publication by Start it up (https://medium.com/swlh).

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Neha Desaraju

Neha Desaraju

102 Followers

Student and engineer working on machine learning & data science