Writing a cross-platform chatbot in Go

Chat apps have long been an important tool for us in the Oneflare Engineering team. We use Slack (previously, HipChat) not only to communicate with each other, but also to provide automated notifications and tooling of various sorts to assist in development workflows. For example, we make use of a third-party Slack integration that messages developers when someone has tagged them as reviewer in a GitHub pull request—and messages them again each day to remind them of any review requests that are outstanding.

Something else we’ve embraced enthusiastically is Kubernetes. We have a Kubernetes cluster dedicated to hosting staging environments for testing new features before they’re shipped to production. We use Drone to automate Docker image builds when new work is pushed to GitHub. One of the nice things about Kubernetes is the ease with which staging environments can be set up and torn down on the basis of these images.

Having said this, actually configuring Kubernetes on your local machine and using it to deploy an appropriate set of pods for your requirements is not always straightforward, especially for new developers. We’re keen for all developers to be able to be able to deploy staging environments with a minimum of fuss, letting QA engineers test work on a production-like environment, and maximising the chance that any defects are surfaced before code is shipped.

For this reason, we built a chatbot, initially for HipChat, that responds to basic commands to deploy, tear down, and query staging environments. Instead of configuring and authenticating with kubectl, developers just had to be signed into HipChat to deploy. In addition, it’s helpful that everyone in our “staging” channel can see what everyone else is deploying in real time, with links to each environment output within the chat.

Earlier this year, Atlassian announced that they would be retiring HipChat in favour of their new chat platform, Stride. A bit later, they announced that Stride would no longer go ahead either; and the obvious choice for us then became to migrate to Slack.

This meant migrating not only our human HipChat users, but also our staging deployment bot. Unfortunately, our existing implementation of said bot, which was written in Ruby, was coupled tightly with HipChat, and had various other limitations and annoyances that we were keen to leave behind.

For this reason, we decided to rewrite our staging deployment bot in a way that would not only address those limitations and annoyances, but also make it as easy as possible to swap out one chat platform for another—or even talk to multiple platforms at once—without having to touch the core business logic that deploys our staging environments.

The majority of our backend code base at Oneflare is written in Ruby; however we had recently written one or two small Hackathon projects in Go, and we thought that for the new chatbot, Go would be a good choice, given its strength with concurrency, as well as the availability of the well-supported kubernetes/client-go library for talking to our staging cluster. It was also a good opportunity to extend our corporate knowledge of Go in the context of a small yet non-trivial project, better equipping us for use cases that are likely to transpire in future, particularly around microservices.

In considering our requirements for the bot server, a certain architecture started to emerge. The project would consist of the following components:

  • A swappable, platform-specific (i.e. Slack specific, HipChat specific, etc.) “portal”, responsible for listening on a given chat platform and sending messages in a normalized, platform-agnostic format to the back-end for processing; as well as receiving normalized responses from the back end and converting them to Slack (or etc.) messages and sending them on the specific platform.
  • A set of back-end “plugins”, which would process each normalized message, performing various actions such as deploying staging environments, and responding back with 0 or more normalized messages.
  • A “connector” in the middle, responsible for coordinating message transfer between the portals and the plugins.

Go turned out to be a great fit for this architecture, for a couple of reasons:

  • The concept of a Go interface allowed us to specify, in code, what a portal should look like and what a plugin should look like, making it easy to start coding a “swappable” architecture from the outset, without getting bogged down in implementation details too early.
  • Go’s channels and goroutines provided the perfect means of passing messages back and forth between portals and plugins, allowing the concept of an ongoing, asynchronous conversation to fall out of the codebase naturally, without any special effort required to ensure conversations would not block each other, while maintaining a logical order of messages within each.

Let’s have a look at how this plays out in the actual codebase.

First of all, we need to define what a message is. We want our messages to be platform-agnostic, so that the plugins at the back-end can process them without worry about e.g. Slack-specific details. We define a Message struct simply as follows:

We’ll skip over the details of the User type for now, suffice to say that it contains logic to identify the same User across multiple platforms, and provide each with a single globally unique identifier such that platform-specific user details remain hidden from all parts of the codebase other than the portal for that particular platform.

Next, we define interfaces to represent the portal concept. Actually there are two concepts here. An individual portal will listen on a particular Slack channel, say; but it will also—usually, but not necessarily—send messages on that channel. The “Go way” is to tend to make interfaces small; this maximises code reuse since a larger number of concrete types can satisfy an interface if it’s smaller. Accordingly, we define two “portal interfaces”, which we call “Listener” and “Sender”:

To implement Listener, a type must have a Listen function that, given a channel, places messages on that channel one by one; and to implement Sender, a type must know how to send messages on its given platform. (To keep things simple for this exposition, we’ve elided some additional method signatures to do with denormalizing messages by expanding user identifiers into platform-specific usernames.)

For convenience, we also ended up defining an interface that’s a composite of both, i.e. which represents a single portal that both listens and sends:

The next major component of our architecture is the plugin. This is a very generic concept; a plugin might do almost anything, or nothing at all, when it is “invoked” with a given message; and it will also be given the opportunity to send messages of its own in response—perhaps multiple messages over an indefinite period of time. So we define an interface around an Invoke method, which gets passed a message, and a channel upon which the plugin can place messages of its own in response.

Finally, we tie these components together using a “connector” component, responsible for passing messages back and forth between the portals and the plugins:

In summary, we instantiate an incomingMessages channel on which the listener portals place each message, in normalized form, as they receive it. The connector iterates over this channel, and passes each message onto each plugin by calling the plugin’s Invoke method. The plugin then decides either to act on or to ignore that message, and places 0 or more messages onto the outgoingMessages channel in response. The connector iterates over this channel, passing each outgoing message onto each sender portal.

The sender portals know how to denormalize the outgoing messages and send them for their particular platform (Slack, HipChat or etc.). We use Go templates to provide placeholders for things like user “mention names” embedded in the message content; the sender portals know how to expand these placeholders appropriately for their particular chat platform. (For brevity we’ve skipped over the relevant code in this example.)

Note we can instantiate as many connectors as we like within a given app. So if we want some plugins operating in one channel, and others in another channel, this can be achieved by instantiating two connectors, and attaching one instance of the Slack listener/sender portal to one, and another to the other. We then attach whatever plugins we like to each connector:

This example is simplified in various ways relative to what we run in production (for example, we have things defined in packages other than main!). But it should provide an indication of how configurable things are with this architecture.

Let’s look at a concrete example of a Plugin. This one quotes randomly from Arnold Schwarzenegger if it detects a message containing the word “chopper”.

We then call connector.AddPlugin(&ChopperPlugin{}) to incorporate our Schwarzenegger quote generator into the app.

There are quite a few details other than this, of course. In practice our plugin logic is much more complex than this, since we handle a range of commands to talk to our Kubernetes staging cluster, as well as providing help output to guide users of the bot. In future, we hope to release the core logic of the bot at https://github.com/Oneflare… so stay tuned!

Leave a reply:

Your email address will not be published.

Site Footer