Building a Slack app for fun and only fun

Here at Anvil, our team has been working remotely during the pandemic, and we’re not all even in the same time zone. Slack has been a lifeline, but it can still be difficult to feel connected as a team.

Although big group Zoom calls are hard to navigate, we’ve had great fun playing Carcassonne together at lunchtime. It gives us an endless supply of conversation starters, and because it’s a turn-based game, everyone gets included in the conversation. And as we all know, the only thing more fun than fun is scheduled fun. So I decided to create an app for our Slack workspace that regularly schedules games of Carcassonne. This guide will show you how I did it.

We’re going to build an interactive Slack app using only Python, and deploy it on the web using Anvil. Here’s what we’re going to build:

Interacting with the finished Carcassonne Slack app

Interacting with the finished Carcassonne Slack app

Overview

In this tutorial, we’ll build an app that:

  1. Allows Slack users to join a board game round
  2. Creates groups of random players
  3. Opens a private message for each group so players can schedule games
  4. Announces the game winners

In order to do this, we’ll use the following Anvil features:

  1. App Secrets to encrypt our API key
  2. HTTP endpoints to communicate with Slack’s API
  3. Data Tables for storing Slack users and game information
  4. Transactions to update our Data Tables
  5. Scheduled tasks so our app can run regularly in the background

Step 1: Create your Slack app

The set-up

The first step is to actually create a new Slack app. Head over to api.slack.com, sign in to your workspace, and click “Create a custom app”. Give it a name, choose your workspace and create your new app.

You should now see a page called “Basic Information”. Scroll down to “Display Information”. Here you can customize your app. Give it a description and an app icon if you’d like.

OAuth and permissions

Next, click on “OAuth & Permissions” from the sidebar, and scroll down to “Scopes”. This is where we decide what our app has permission to do. You can read about all the possible scopes and what they allow here. Clicking on a scope will tell you what methods it is compatible with and conversely, clicking on a method here will tell you what scope(s) it needs.

We’ll need a few Bot Token scopes for our Carcassonne bot to work properly: channels:manage, channels:read, chat:write, im:history, groups:write, im:write, mpim:write, incoming-webhook, and users:read. We don’t need any User Token scopes.

Once you’ve added the scopes (you can always add more later), you’ll be able to install the app to your workspace. Scroll to the top of the page, click the green “Install to Workspace” button and allow the app access to your workspace. You will now have a Bot User OAuth Access Token. Copy this, we’ll need it in a minute.

We also want to enable event subscriptions and interactivity, but we’ll first need a URL that Slack can send HTTP POST requests to. In order to do that, let’s set up our Anvil app.

Step 2: Set up an Anvil app and HTTP endpoints

Note: This guide includes screenshots of the Classic Editor. Since we created this guide, we've released the new Anvil Editor, which is more powerful and easier to use.

All the code in this guide will work, but the Anvil Editor will look a little different to the screenshots you see here!


Create an Anvil app

We now need to create a new Anvil app, but we don’t actually need a front-end for it. Slack will be our front end, and everything we write for our Slack app will be in Server code in our Anvil app. As we step through this tutorial, we will build a temporary front-end for our Anvil app so that we can call our server functions and test out our app’s functionality before it’s completely finished.

Create a new Anvil app, re-name it and add a server module.

The first thing we need to do is import the Slack Python SDK, which is included in the Full Python Runtime. At the top of the server module, add from slack_sdk import WebClient. We also need to add import json. We now need the bot token we copied in the last step. We should store the token in an App Secret, so that it’s encrypted for us. (Follow the linked instructions, if you don’t already know how to store a secret.) I named my token bot_token.

Now we can set up the web client with our bot token. Add this to your server code:

token = anvil.secrets.get_secret('bot_token')
client = WebClient(token=token)

Set up an endpoint for incoming messages

Let’s now write a server function to deal with incoming messages to our app. We can easily set up an HTTP endpoint by using the @anvil.server.http_endpoint() decorator and supplying it with a path. For now, let’s just set up a function that decodes the body of the incoming request as a JSON object and prints it out. This will be printed in our App’s logs. Since not all web APIs are built the same, it’s a good idea to print out and inspect the request body. That way, we can see how its formatted and know how to handle the incoming requests.

@anvil.server.http_endpoint('/incoming_msg')
def incoming_msg(**kwargs): 
  json = anvil.server.request.body_json
  print(json)

Because I added the path /incoming_msg to my function, I now have an HTTP endpoint at https://<my-app>.anvil.app/_/api/incoming_msg. To get the actual app URL, go to “Publish app” in the Gear menu and copy your app’s URL.

Now we need to go back to the Slack API, and tell it where to send message requests. In the Slack API, click on “Event Subscriptions” in the side bar and toggle “Enable Events” to be on. In “Request URL”, paste your app URL here and add /_/api/incoming_msg (or whatever path you supplied to the HTTP endpoint) to the URL. As it says on that page, Slack is now going to send a request to our URL with a challenge parameter and we need to respond with the challenge value. The request should look like what Slack shows here.

Let’s go back to our app and see what the request looks like in our app and then change our function to return the challenge parameter. In the App Logs, we should see the request that came from Slack and the JSON object printed out. It should look just like Slack said it would.

Let’s now update our incoming_msg function so that it returns the challenge parameter when we have a url_verification event:

@anvil.server.http_endpoint('/incoming_msg')
def incoming_msg(**kwargs): 
   json = anvil.server.request.body_json
   print(json)  
   if json.get('type') == "url_verification":
      return json['challenge']

Go back to “Event Subscriptions” in the Slack API, enable events again and re-add the request URL. This time, the URL should be verified. Without leaving the page, we should now subscribe to bot events. For the Carcassonne app we are building, we only need message.mpim. This allows the app to read messages in a DM - we’ll see later why we want this. Make sure to click “Save Changes” at the bottom of the page and reinstall the slack app to your workspace.

If you navigate away from the page after adding the request URL witout subscribing to any bot events, the URL you added won’t be saved. You’ll have to enable events again and re-enter the URL.

Set up an endpoint for interactivity

Let’s now enable interactions, so we can handle when users interact with our Slack app. We need to go back to our Anvil app and set up another HTTP endpoint to receive interactions. When someone interacts with our app, Slack will send us a request with a payload parameter. We should now set up our endpoint and a function that prints out this payload parameter so we can see what it looks like when someone interacts with our app. As mentioned before, APIs can be inconsistent, even within the same API, so it’s always a good idea to read the documentation and to print out the request body and any parameters it may contain.

@anvil.server.http_endpoint('/interaction')
def interaction(payload, **kwargs): #payload is provided as a form parameter in the request body
  #decode payload as json
  payload = json.loads(payload)
  print(payload)

We can now enable interactivity in the Slack API. Go to “Interactivity & Shortcuts” in the sidebar and toggle Interactivity to be on. Add your Anvil app’s URL followed by “/_/api/interaction” (or whatever path you provided to the HTTP endpoint). Make sure to save your changes.

We have now set up our Slack app so that it can post to Slack and users can interact with it. But right now our app doesn’t do anything and there’s nothing for users to interact with. Let’s fix that.

Step 3: Create Data Tables of users and games

Create a Data Table of Slack users

Since we’re building an app to schedule Carcassonne games, the first thing we need is a database of eligible players. Let’s create an empty Data Table with a column for the player’s name, their Slack User ID and a column indicating whether or not they will be playing Carcassonne this round. Let’s name this Data Table slack_users.

We now need a function that we can call just once to get a list of Slack users in our workspace, which we can use to populate the name and user ID columns of the slack_users Data Table. We can use the method users_list to get a list of all the users in our Slack workspace. As before, I first printed out Slack’s response before writing the rest of the function. That way, I knew how to extract the list of Slack users. Here’s the function I wrote to return the Slack users and add their IDs and names to the slack_users table:

@anvil.server.callable
def populate_slack_table():
  #get list of slack users
  response = client.users_list()
  print(response)
  members = response['members']
  #create a list of dicts containing name and slack id of each user
  users = [{
      'name': m['name'],
      'id': m['id'],
      } for m in members]

  for user in users:
    #check that the user isn't already in the table
    if not app_tables.slack_users.get(id=user['id']): 
    app_tables.slack_users.add_row(name=user['name'], id=user['id'])
If any methods (such as users_list) aren’t working, make sure you’ve added the required scope(s) to OAuth and Permissions.

Create a Data Table for games

We now have a Data Table with all the possible Carcassonne players. Since we might have more than one game during a single round, we should also create a Data Table of Carcassonne games so we can assign Slack users to the games. This way, we can also store the winner of each game. Add another Data Table to your Anvil app called games with four columns. We need a column called players that links to multiple rows of the slack_users table. We also need a column called winner that links to a single row of the slack_users table. Our table also needs a created column and a True/False column to indicate whether or not the game is currently active.

Now that we have a games Data Table, let’s edit our slack_users table to include a column that links back to the games Data Table. That way we can indicate which game each player is currently a part of.

Step 4: Building the Slack UI

So far, our Slack app still doesn’t do anything. Our Anvil app is set up to handle incoming message and interaction requests, and we’ve set up two Data Tables to store information about our players and games. The next thing we need to do is build our User Interface within Slack. We’ll want the app to announce to Slack that a new round of Carcassonne games are about to start, but we should also ask our co-workers if they want to play this round. As we previously agreed, scheduled fun is the best type of fun, but we can also agree that forced fun is the worst type of fun. So we’ll also need a way for co-workers to opt-in to playing each round.

The Block Kit

Slack has a nice tool that allows us to design our app’s interactivity. You can play around in the Block Kit Builder to design what you want your Slack UI to look like. On the left of the builder is what the blocks will look like in Slack and on the right, is the JSON object representing those blocks. I built a Block Kit with a markdown (mrkdwn) section (so that I could use emojis 😉) and a button that says “Count me in!”. The button has an action_id of join_round. When someone interacts with this button, this action_id will be part of the request sent to our Anvil app.

When you’ve finished designing your Block Kit, you’ll need to change any true value in the JSON object to True. We can now copy this JSON to use in a function that will generate our interactive blocks. You only need to copy what is inside the "blocks" key.

Generate interactive blocks

In our Anvil app, we can now write a function that generates these blocks but doesn’t yet send them to Slack. We’ll want to update this block whenever players opt into the game, so we’ll write a reusable function to generate the block description. The function should first search for any active users in our slack_users Data Table to see who has already joined the round. If there are active users, it should create a list of the their names and print these names in our blocks.

def generate_new_round_blocks():
  #empty string for when there are no active players
  players = ''

  #create capitalized list of players active this round
  users = app_tables.slack_users.search(active_user=True)
  users = [user['name'].capitalize() for user in users]

  if len(users) == 1: #deal with grammar for a single player
    players = ' ' + users[0] + ' is already in!'

  elif len(users) > 1:
    players = ' ' + ', '.join(users[:-1]) + ' and ' + users[-1] + ' will be competing!'

  return [
		{
			"type": "section",
			"text": {
				"type": "mrkdwn",
				"text": "Hear ye! Hear ye! Calling all citizens of Carcassonne willing to compete for the coveted crown! " + players
			},
			"accessory": {
				"type": "button",
				"text": {
					"type": "plain_text",
					"text": "Count me in!",
					"emoji": True
				},
				"value": "true",
				"action_id": "join_round",
				"style": "primary"
			}
		}
	]

Post blocks to a channel

We now need somewhere for our app to post these blocks. Go to your Slack workspace and add a new channel. It can’t have the same name as your Slack app, so choose something else. At the top of the channel, click “Add an app”, and choose your new app.

We can now write a function that starts a new round for playing Carcassonne. The function should first set up a new round by setting the active columns in the games and slack_users tables to False and by clearing the current_game column of the slack_users table. Then it should use the method chat_postMessage to send the generate_new_round blocks to a channel.

@anvil.server.background_task
@anvil.server.callable
def start_new_round():
  print("starting new round")

  users = app_tables.slack_users.search()
  for user in users:
    user['active_user'] = False
    user['current_game'] = None

  games = app_tables.games.search()
  for game in games:
    game['active'] = False

  response = client.chat_postMessage(channel="#carcassonne-games", blocks=generate_new_round_blocks())

Testing the functions

We can now build a temporary front-end for our Anvil app so that we can test out the functions we just wrote. As long as the server functions we wrote have the @anvil.server.callable decorator, we can call them from the client. We should add two buttons to the Anvil app’s front-end, one that calls the populate_slack_table function and one that calls start_new_round(). Let’s now click the button to populate the slack_users table and check that our Data Table is indeed populated with a list of users in our Slack workspace. Next, click the button to call start_new_round(). If we’ve done everything correctly, we should see our blocks in the channel!

Interacting with blocks

But what happens when someone clicks “Count me in”? Right now, nothing. We need to catch that interaction and then update our new round blocks using the chat_update method. chat_update just requires us to pass in the channel ID and timestamp of the post we want to update, and then we can change our blocks to list the player who clicked “Count me in!”.

If we click “Count me in!” in Slack, we should see the payload of our request in our app’s logs (because we are printing out the payload in our interaction function). If we look at this JSON object (you can use an online JSON viewer to read it better), we can see that it’s type is labeled as block_actions. This makes sense because we know its part of a Block Kit. At the end of the JSON, there is a key called actions which is a list with a single dictionary. This dictionary has an action_id of join_round, which is what we assigned for the “Count me in!” button.

App logs when 'Count me in!' button is clicked

In order to catch the interaction of someone clicking “Count me in”, we need to check if the type of the interaction is block_actions and if the action_id is join_round. Then we need to collect the channel id and the timestamp of the message we want to update. This information is actually in a couple places, but I pulled the channel id from payload[‘channel’][‘id’] and the timestamp from payload[‘message’][‘ts’]. The last thing we need is the User ID of the person who clicked the button. We can find this in payload[‘user’][‘id’].

We can then find the user’s corresponding row in our slack_users table and set the active_user column to be True. Now, we can update the message so that it tells us who has already clicked “Count me in!”. We just need to pass our token, the channel_id, the message timestamp and the blocks to display (generated from generate_new_round_blocks) to chat_update. This time, our generate_new_round_blocks function will again search for active users and will find one! It will then update the message accordingly.

Let’s now edit our interaction() function to catch when someone clicks “Count me in”:

@anvil.server.http_endpoint('/interaction')
def interaction(payload, **kwargs): #payload is provided as a form parameter in the request body
  #decode payload as json
  payload = json.loads(payload)
  print(payload)

  if payload.get('type') == 'block_actions':
    #in a for loop because payload['actions'] is a list with a single dictionary
    for a in payload['actions']: 
      if a['action_id'] == "join_round": #if the interaction is from someone clicking the "Count me in" button
        user = app_tables.slack_users.get(id=payload['user']['id']) #find the user who clicked the button in the data table...
        user['active_user'] = True #...and make them an active player
        #update the new round blocks with new players (ts is timestamp)
        client.chat_update(token=token, channel=payload['channel']['id'], ts=payload['message']['ts'], blocks=generate_new_round_blocks())

Let’s test our our changes. Go back to your Slack workspace and click “Count me in!”. The blocks in Slack should update to say you’re playing in the round. Go back to your Anvil app and makes sure that active_user is now True for you in the slack_users table.

Step 5: Choose game groups

Now that we know who is playing, let’s assign them to game groups. Let’s first get a list of all the players from our slack_users table who are active users. We should then shuffle the list so the teams are different every time.

Now it’s time for some math. The ideal number of board game players is around 4 (to allow for good conversation and less waiting around for your turn). What we can do is divide the number of players by 4 and round this number to the nearest whole number. This will be the number of games we want to schedule.

For each game in the round, we’ll add a game to the games Data Table. In the same step, we can add the games table rows to a list, so that we can assign players to the games. We can cycle through the players in our active players list and assign each player to a game depending on how many games we have. We’ll also update the slack_users table to link the active players to their current game. We’ll do this all in a transaction by using the @tables.in_transaction, so we make sure all the Data Table operations are carried out as a group.

@anvil.server.background_task
@anvil.server.callable
@tables.in_transaction
def choose_groups(): 
  player_rows = list(app_tables.slack_users.search(active_user=True))
  random.shuffle(player_rows)
  #game count is either 1 or the number of players divided by 4 rounded to the nearest whole number
  game_count = max(int(round(len(player_rows)/4)), 1) 
  #add a row in the games table for each new game 
  games = [app_tables.games.add_row(players=[], created=date.today(), active=True) for i in range(game_count)]

  for i in range(len(player_rows)): 
    #this will cycle through each game in games
    game = games[i%game_count] 
    game['players'] += [player_rows[i]] #add the player to the games table
    player_rows[i]['current_game'] = game #assign the game in the Slack Users table

Let’s test this out. We can add another button to the front end of our Anvil app that, when clicked, calls the choose_groups function. We can test it better if we can get multiple co-workers to click “Count me in!”. After a few people have clicked the button in Slack, run the choose_groups function in your Anvil app. You should add print statements and check the Data Table to make sure everything has worked properly.

Step 6: Announce the games

Now that we’ve assigned games to the players, we should let them know who they’ll be playing with. Our slack bot can post in channels and in direct messages. Let’s set up our Slack app so that it posts the teams in the public channel and also opens a private DM for each Carcassonne group. That way, players can arrange a time to play Carcassonne privately.

From our games table, we can collect the active games into a list and extract the players from those games. We’ll have a list where each item is the players column from the games table (which is a list of rows from the slack_users table). If we cycle through this list, we can find the Slack User IDs of each player in each game. We can then post in the Carcassone channel per game to announce who is playing together.

Then, to start a direct message with the game players, we use the conversations_open method and pass it the IDs of the users we want to open a message with. We need the Slack User IDs to open a direct message and to @ mention someone. If you look at the linked documentation for the method, it will tell you the format Slack expects for the users argument. I’ve also added to my function so that it chooses a player at random to be in charge of getting the game started.

@anvil.server.background_task
@anvil.server.callable
def announce_games(): 
  current_games = app_tables.games.search(active=True)
  #list of players row from the games data table - players_rows will have one item per # of games
  player_rows = [game['players'] for game in current_games]
  for i, group in enumerate(player_rows):
    players = [player['id'] for player in group] #collect ids of active players
    player_mentions = [f'<@{player}>' for player in players] #turn theses into mentions 
    random_id = random.sample(player_mentions, k=1)[0]
    
    #post message in DM
    response = client.conversations_open(users=",".join(players))
    channel = response['channel']['id']
    #don't actually @carcassonne because it will announce itself as the winner
    response = client.chat_postMessage(channel=channel, 
                                       text=f"By royal degree of the Kingdom of Carcassonne :european_castle:, you {str(len(players))} citizens have been chosen to compete against each other for the crown! :crown: {random_id} you have been randomly chosen to start the royal game. \n When you have finished your game, please tell me who won in this message (like this: @Carcassonne won).")
    
    #post message in channel
    response = client.chat_postMessage(channel="#carcassonne-games", text=", ".join(player_mentions[:-1]) + ' and ' + player_mentions[-1] + ' are vying for the Carcassonne throne together in Game ' + str(i+1))

We should now test this out. Add another button to your Anvil app that calls announce_games. Click this and make sure your bot posts in both the public channel and in a DM.

Step 7: Announce the winners

Great! Let’s assume everyone has played their Carcassonne games. We should announce to everyone who has won each game so they can bask in glory. We already have a column in the games table for the winner of the game. Once we’ve added the winner, it will be easy to post to Slack in the same way we’ve done before. But we first need to find a way to tell the Slack app who won.

Telling the app who won

There are multiple ways to go about this. What I did was add to the incoming_msg function to look for any messages which contained a user mention followed by “won” using a simple regex. Mentions in slack (like @brooke) are formatted as <@user_id> when they are passed as a request. If a message matching the regex is sent to a DM that the Carcassonne app is a part of, then we can update the games table with the winner of the game.

@anvil.server.http_endpoint('/incoming_msg')
def incoming_msg(**kwargs): 
  print(kwargs)
  #request body contains the json encoded payload
  json = anvil.server.request.body_json
  print(json)
  if json.get('type') == "url_verification":
    return json['challenge']
  
  elif json.get('event').get('type') == "message":
    text = json['event']['text']
    #look for a mention and the word won after
    match = re.search('(<@(U.+)>) ?won', text) 
    if match:
      print("winner match!")
      #match.group(1) is the entire user mention, match.group(2) is just the user id inside of the <@>
      response = client.chat_postMessage(channel=json['event']['channel'], 
                                         text=f"Congratulations to {match.group(1)}! The kingdom will be notified before the next round.")
      winner = app_tables.slack_users.get(id=match.group(2)) 
      #update the users table to include winner of round
      winner['current_game']['winner'] = winner

Telling the world who won

Now that we’ve updated the games table with the winners, we can announce the winners to the world! (or just to your slack workspace.) We can write a function to search for the active games and the winner of those games. Then we just chat_postMessage to the public Carcassonne channel. To get around the messiness of grammar, I used an if/else clause to post a different message depending on if there is only one or if there is more than one winner.

@anvil.server.background_task    
@anvil.server.callable
def announce_winners(): 
  games = app_tables.games.search(active=True)
  winners = [w['winner']['id'] for w in games]
  winner_mentions = [f'<@{w}>' for w in winners]

  if len(winners) == 1: 
    response = client.chat_postMessage(channel="#carcassonne-games", text=f"I hereby dub {winner_mentions[0]} as this round's ruler of Carcassone!")

  elif len(winners) > 1:
    response = client.chat_postMessage(channel="#carcassonne-games", text="I hereby dub " + ", ".join(winner_mentions[:-1]) + "and " + winner_mentions[-1] + "as this round's rulers of Carcassonne")

Testing it out

Now let’s test this out. In the DM the Carcassonne app opened, mention yourself followed by the word “won”. Now check the games Data Table and make sure that you’re listed as the winner. We should now test out the winner announcement. Add another button to your Anvil app that calls announce_winners. When you click this, the Carcassonne app should post in the Carcassonne channel announcing you as the winner (and you didn’t even have to play a game!).

Step 8: Scheduled tasks

We’re nearly finished. Our Carcassonne app does everything we need it to do, but we have to click buttons in our Anvil app for it to work. Let’s automate the app so that it runs these functions in the background at scheduled times. For this, we’ll use scheduled tasks. To run a task in the background, we first need to add the @anvil.server.background_task decorator. Then we can call the task or we can schedule it to run at a specific time.

In the Gear menu of your Anvil app, click on Scheduled Tasks. In the popup, add start_new_round as a scheduled tasks and set it to run every 2 weeks (or more or less frequently based on the general level of enthusiasm your team has for Carcassonne). You can then choose what day of the week and the time of the day you want the task to run. Do the same for the other tasks: choose_groups, announce_games, and announce_winners. Make sure to space them out as appropriate.

Let the games begin!

That’s it! We’ve now used the Slack API to build a simple app to schedule board game sessions with our co-workers.

You can check out the source code here:


New to Anvil?

If you’re new here, welcome! Anvil is a platform for building full-stack web apps with nothing but Python. No need to wrestle with JS, HTML, CSS, Python, SQL and all their frameworks – just build it all in Python.

Yes – Python that runs in the browser. Python that runs on the server. Python that builds your UI. A drag-and-drop UI editor. We even have a built-in Python database, in case you don’t have your own.

Why not have a play with the app builder? It’s free! Click here to get started:


Want to try another tutorial? Learn about databases in Anvil with our Feedback Form tutorial: