Rapid Development of Serverless Chatbots with Cloudflare Workers and Workers KV

Rapid Development of Serverless Chatbots with Cloudflare Workers and Workers KVRapid Development of Serverless Chatbots with Cloudflare Workers and Workers KV

I’m the Product Manager for the Application Services team here at Cloudflare. We recently identified a need for a new tool around service ownership. As a fast growing engineering organization, ownership of services changes fairly frequently. Many cycles get burned in chat with questions like "Who owns service x now?

Whilst it’s easy to see how a tool like this saves a few seconds per day for the asker and askee, and saves on some mental context switches, the time saved is unlikely to add up to the cost of development and maintenance.

= 5 minutes per day
x 260 work days = 1300 mins / 60 mins = 20 person hours per year

So a 20 hour investment in that tool would pay itself back in a year valuing everyone’s time the same. While we’ve made great strides in improving the efficiency of building tools at Cloudflare, 20 hours is a stretch for an end-to-end build, deploy and operation of a new tool.

Enter Cloudflare Workers + Workers KV

The more I use Serverless and Workers, the more I’m struck with the benefits of:

1. Reduced operational overhead

When I upload a Worker, it’s automatically distributed to 175+ data centers. I don’t have to be worried about uptime – it will be up, and it will be fast.

2. Reduced dev time

With operational overhead largely removed, I’m able to focus purely on code. A constrained problem space like this lends itself really well to Workers. I reckon we can knock this out in well under 20 hours.

Requirements

At Cloudflare, people ask these questions in Chat, so that’s a natural interface to service ownership. Here’s the spec:

Use CaseInputOutput
Add@ownerbot add Jira IT http://chat.google.com/room/ABC123Service added
Delete@ownerbot delete JiraService deleted
Question@ownerbot KibanaSRE Core owns Kibana. The room is: http://chat.google.com/ABC123
Export@ownerbot export[{name: "Kibana", owner: "SRE Core"...}]

Hello @ownerbot

Following the Hangouts Chat API Guide, let’s start with a hello world bot.

  1. To configure the bot, go to the Publish page and scroll down to the Enable The API button:
  2. Enter the bot name
  3. Download the private key json file
  4. Go to the API Console
  5. Search for the Hangouts Chat API (Note: not the Google+ Hangouts API)

    Rapid Development of Serverless Chatbots with Cloudflare Workers and Workers KV

  6. Click Configuration onthe left menu
  7. Fill out the form as per below [1]

    • Use a hard to guess URL. I generate a guid and use that in the url.
    • The URL will be the route you associate with your Worker in the Dashboard
      Rapid Development of Serverless Chatbots with Cloudflare Workers and Workers KV
  8. Click Save

So Google Chat should know about our bot now. Back in Google Chat, click in the "Find people, rooms, bots" textbox and choose "Message a Bot". Your bot should show up in the search:

Rapid Development of Serverless Chatbots with Cloudflare Workers and Workers KV

It won’t be too useful just yet, as we need to create our Worker to receive the messages and respond!

The Worker

In the Workers dashboard, create a script and associate with the route you defined in step #7 (the one with the guid). It should look something like below. [2]

Rapid Development of Serverless Chatbots with Cloudflare Workers and Workers KV

The Google Chatbot interface is pretty simple, but weirdly obfuscated in the Hangouts API guide IMHO. You have to reverse engineer the python example.

Basically, if we message our bot like @ownerbot-blog Kibana, we’ll get a message like this:

 { "type": "MESSAGE", "message": { "argumentText": "Kibana" } }

To respond, we need to respond with 200 OK and JSON body like this:

content-length: 27
content-type: application/json {"text":"Hello chat world"}

So, the minimum Chatbot Worker looks something like this:

addEventListener('fetch', event => { event.respondWith(process(event.request)) }); function process(request) { let body = { text: "Hello chat world" } return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-cache" } });
}

Save and deploy that and we should be able message our bot:

Rapid Development of Serverless Chatbots with Cloudflare Workers and Workers KV

Success!

Implementation

OK, on to the meat of the code. Based on the requirements, I see a need for an AddCommand, QueryCommand, DeleteCommand and HelpCommand. I also see some sort of ServiceDirectory that knows how to add, delete and retrieve services.

I created a CommandFactory which accepts a ServiceDirectory, as well as an implementation of a KV store, which will be Workers KV in production, but I’ll mock out in tests.

class CommandFactory { constructor(serviceDirectory, kv) { this.serviceDirectory = serviceDirectory; this.kv = kv; } create(argumentText) { let parts = argumentText.split(' '); let primary = parts[0]; switch (primary) { case "add": return new AddCommand(argumentText, this.serviceDirectory, this.kv); case "delete": return new DeleteCommand(argumentText, this.serviceDirectory, this.kv); case "help": return new HelpCommand(argumentText, this.serviceDirectory, this.kv); default: return new QueryCommand(argumentText, this.serviceDirectory, this.kv); } }
}

OK, so if we receive a message like @ownerbot add, we’ll interpret it as an AddCommand, but if it’s not something we recognize, we’ll assume it’s a QueryCommand like @ownerbot Kibana which makes it easy to parse commands.

OK, our commands need a service directory, which will look something like this:

class ServiceDirectory { get(serviceName) {...} async add(service) {...} async delete(serviceName) {...} find(serviceName) {...} getNames() {...}
}

Let’s build some commands. Oh, and my chatbot is going to be Ultima IV themed, because… reasons.

class AddCommand extends Command { async respond() { let cmdParts = this.commandParts; if (cmdParts.length !== 6) { return new OwnerbotResponse("Adding a service requireth Name, Owner, Room Name and Google Chat Room Url.", false); } let name = this.commandParts[1]; let owner = this.commandParts[2]; let room = this.commandParts[3]; let url = this.commandParts[4]; let aliasesPart = this.commandParts[5]; let aliases = aliasesPart.split(' '); let service = { name: name, owner: owner, room: room, url: url, aliases: aliases } await this.serviceDirectory.add(service); return new OwnerbotResponse(`My codex of knowledge has expanded to contain knowledge of ${name}. Congratulations virtuous Paladin.`); }
}

The nice thing about the Command pattern for chatbots, is you can encapsulate the logic of each command for testing, as well as compose series of commands together to test out conversations. Later, we could extend it to support undo. Let’s test the AddCommand

 it('requires all args', async function() { let addCmd = new AddCommand("add AdminPanel 'Internal Tools' 'Internal Tools'", dir, kv); //missing url let res = await addCmd.respond(); console.log(res.text); assert.equal(res.success, false, "Adding with missing args should fail"); }); it('returns success for all args', async function() { let addCmd = new AddCommand("add AdminPanel 'Internal Tools' 'Internal Tools Room' 'http://chat.google.com/roomXYZ'", dir, kv); let res = await addCmd.respond(); console.debug(res.text); assert.equal(res.success, true, "Should have succeeded with all args"); });
$ mocha -g "AddCommand" AddCommand add ✓ requires all args ✓ returns success for all args 2 passing (19ms)

So far so good. But adding commands to our ownerbot isn’t going to be so useful unless we can query them.

class QueryCommand extends Command { async respond() { let service = this.serviceDirectory.get(this.argumentText); if (service) { return new OwnerbotResponse(`${service.owner} owns ${service.name}. Seeketh thee room ${service.room} - ${service.url})`); } let serviceNames = this.serviceDirectory.getNames().join(", "); return new OwnerbotResponse(`I knoweth not of that service. Thou mightst asketh me of: ${serviceNames}`); }
}

Let’s write a test that runs an AddCommand followed by a QueryCommand

describe ('QueryCommand', function() { let kv = new MockKeyValueStore(); let dir = new ServiceDirectory(kv); await dir.init(); it('Returns added services', async function() { let addCmd = new AddCommand("add AdminPanel 'Internal Tools' 'Internal Tools Room' url 'alias' abc123", dir, kv); await addCmd.respond(); let queryCmd = new QueryCommand("AdminPanel", dir, kv); let res = await queryCmd.respond(); assert.equal(res.success, true, "Should have succeeded"); assert(res.text.indexOf('Internal Tools') > -1, "Should have returned the team name in the query response"); })
})

Demo

A lot of the code as been elided for brevity, but you can view the full source on Github. Let’s take it for a spin!

Rapid Development of Serverless Chatbots with Cloudflare Workers and Workers KV

Learnings

Some of the things I learned during the development of @ownerbot were:

  • Chatbots are an awesome use case for Serverless. You can deploy and not worry again about the infrastructure
  • Workers KV means extends the range of useful chat bots to include stateful bots like @ownerbot
  • The Command pattern provides a useful way to encapsulate the parsing and responding to commands in a chat bot.

In Part 2 we’ll add authentication to ensure we’re only responding to requests from our instance of Google Chat


  1. For simplicity, I’m going to use a static shared key, but Google have recently rolled out a more secure method for verifying the caller’s authenticity, which we’ll expand on in Part 2. ↩︎
  2. This UI is the multiscript version available to Enterprise customers. You can still implement the bot with a single Worker, you’ll just need to recognize and route requests to your chatbot code. ↩︎

Source

You might also like:

Comment on this post

Loading Facebook Comments ...
Loading Disqus Comments ...

No Trackbacks.