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 Case
Input
Output
Add
@ownerbot add Jira IT http://chat.google.com/room/ABC123
Service added
Delete
@ownerbot delete Jira
Service deleted
Question
@ownerbot Kibana
SRE 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.
To configure the bot, go to the Publish page and scroll down to the Enable The API button:
Enter the bot name
Download the private key json file
Go to the API Console
Search for the Hangouts Chat API (Note: not the Google+ Hangouts API)
Click Configuration onthe left menu
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
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:
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]
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:
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!
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
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. ↩︎
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. ↩︎