by

Building a Slack slash command with Elixir and Phoenix

tldr – Elixir and Phoenix make building a Slack Slash Command a breeze with their composable web app style and pattern matching. Checkout the Chat Toolbox (currently in beta) if you want to see it in action and have a way to manage GitHub Issues from Slack

I recently started a new project over at toolbox.chat that allows you to work with your GitHub Issues within Slack. It was a lot of fun to build and Elixir/Phoenix made it a breeze. Here’s a little of what I learned building it out.

A slash command is a Slack extension that uses one a / to kick it off. The app I built implements /issue . When Slack gets one of these it makes an HTTP request to your app and will display the response to the user. Simple enough. When you configure your app within Slack you can specify the URL to post to, and if your app adds multiple slash commands each one can have it’s own URL.

So the first step was setting up the application within Slack to make those requests to my app. This is where I made heavy use of ngrok to be able to have Slack make requests to Phoenix running on my laptop.

Once you have a request, it’s time to get to work figuring out what to do with it. Because I wanted my app to have a single entrypoint, I had some string parsing to do. This is where Plug and Elixir’s pattern matching came in extremely handy.

I wanted to be able to:

  • /issue 3 should show me issue number 3 on the repo I have selected for this channel
  • /issue 3 comment <comment> should make a comment
  • /issue actioncable -- bug should search for issues with “actioncable” and the label “bug”
  • /issue created should show me issues I’ve created
  • /issue elixir-lang/plug -- Kind:Feature should show me issues on the Plug repo with the label “Kind:Documentation”

So I had a fair few paths I needed to cover in my parsing. I ended up with a handful of Plugs that split it up into steps, each one a little more expensive than the last.

We kick off our plug-chain by two plugs that, if this is a slash command, will lookup a user record for the user. If we don’t know who you are, we bounce out of the pipeline and ask you to login or register.

After that, we have a plug that will put a github_client into private so that it’s available based on the user we just looked up.

Now that we know who you are and how to talk to GitHub, we enter into our main preprocessor Plug.

For better or worse I channeled my Ruby Ruleby days and ended with a SlackCommandPreprocessor plug that looked something like:


def call(%{private: %{phoenix_action: :issue}, params: %{"text" => command}} = conn, opts) do
  command
  |> RepoInputResolver.process
  |> existentialism
  |> determine_permission
  |> SlackCommandResolver.process
  |> instrument
  |> display_help_if_errors(conn)
end

To break that down:

  1. RepoInputResolver will determine what repo we’re dealing with. In here I included applying defaulted repos (based on settings stored in Postgres), as well as “guessing” the repo owner if we had something we thought was repo but we weren’t sure of the owner.
  2. existentialism will take what came out of the RepoInputResolver and check to see if it is an exisiting repo or repo/issue id combo.
  3. determine_permission will check to see if the logged in user has admin permissions on this repo. We use this to only display close/assign/tag buttons in Slack if you can actually do those things.
  4. SlackCommandResolver is what takes the info we have here and relates it to an opcode. At this point we have all the info we can squeeze out of the string itself and need to figure out what we’re trying to do before we can parse it further. I made a handful of opcodes that get used in the controller to determine what actions to take.
  5. instrument will stash some metadata in Sentry and Scout so that I can better figure out what happened when errors crop up.
  6. display_help_if_errors will hopefully hint at something fixable if we got this far and couldn’t figure out what is happening. If we have absolutely no idea what you’re getting at we point you to our docs.

So that’s a lot to go into one Plug. This project is still new and growing and so it will probably get split out into a couple ones. But to ramp up it was handy to have it all in once place.

Probably the most fun bits to write were the RepoInputResolver and SlackCommandResolver which has already gone through a few iterations.

At first, RepoInputResolver was just returning a Map, and that map was being fed to the rest of the controller to figure out what we were doing. The problem is, several of the commands require additional state once you know that you’re doing that specific command (e.g. query for people to assign an issue to) and it was getting all messy as far as knowing what info we were working with.

So I wrote a SlackCommand struct that had an opcode and a meta Map that could be used for storing additional data. The SlackCommandResolver looks at what information RepoInputResolver was able to find and based on the availability of a specific non-defaulted repo or issue id etc., as well as typically the first word after that, we’re able to assign an opcode to either list my issues, filter my issues, create a new issue or close an issue or whatever.

I was actually really surprised by how terse Elixir can make this. While most of my lines end up being way over 80 chars once I get all my guards in place, it’s able to take a fairly complex task and simplify it in to a series of 3-line functions. So far I’m quite happy with the result.

This is probably already too long of an article, but I’m not done yet. I’ll have to write another that deals with how Slack will send you interactive message responses and dealing with parsing those and responding into the same message slot.

But for now I’ll leave you with this. Hopefully you found it interesting, and I hope you’ll try out the beta of toolbox.chat and find it useful.

Happy coding.