In this project, I'm building a lookup tool; Users provide a key and the app returns a value. In practice this is doing something for my job but for tutorial purposes let's find out who is the governor of any given state.

Introduction

Most of the text of this post will be documenting the steps I take, because no one can remember all those details. The point of the post, though, is the architecture of the app: The basic gist is that users give the app some info and get some info back. By abstracting this interface aspect from the actual data processing I can provide multiple interfaces to expose the same procedures and data. We'll have a generic HTML interface for testing, plus a Slack Bot and Google Voice Assistant integration for the actual intended use.

Spinning Up a Work Environment

Cloud 9 is truly remarkable. Every time I use it I think I must be remembering it being better than it really was, but it always lives up to the hype. Go ahead and sign in, making an account if needed. We'll spin up an easy-peasy PHP/Apache configuration.

Once it loads, we can hit the big ol' Run button, smack dab in the middle of the toolbar at the top of the window. This will spin up a webserver that we can access by clicking the link generated in the console in the bottom of the window.

HTML Interface

Right out of the gate we have a hello-world.php being served up. I like a more expository name, so let's duplicate the file and call one index.php and the other processing.php. You probably already know that an index.php will be opened automatically if you visit the containing folder.

Recall that this interface is primarily for testing, so we won't waste any time with bells and whistles. We'll replace the default code with:

  • Something to receive user input
  • Something to log data
  • Something to process data
  • Something to present data

Like so:

include('processing.php'); 
$data = $_REQUEST; 
file_put_contents('request.log',json_encode($data).PHP_EOL,LOCK_EX|FILE_APPEND); 
$data = governorLookup($_REQUEST['state']); 
echo(json_encode($data)); 

First we use include() so that, eventually, we'll have access to whatever handy dandy code we write in processing.php. We're capturing the $_REQUEST super-global variable. It includes, among other things, whatever the user sends via GET or POST. Then we store that request in a text file that we can look into during testing. The PHP script will have access to the request, but this lets the human read it after the script has run (successfully or otherwise) to get insight into what's going on. Finally we run our code from our other file and present it. I like to present data in JSON so I can use a viewer if things get unwieldy. Don't bother visiting that page yet- We'll only have errors because processing.php doesn't do anything yet.

Data Processing

To get started, let's provide the bare minimum. No one should visit this script, so we can forego the HTML. index.php calls a governorLookup() function. Let's write it now.

function governorLookup($input){
  $governors = array(); 
  if(isset($governors[$input])){ 
    return $governors[$input]; 
  } 
  return false; 
} 

This code checks an array $governors for some key, $input. If the array has some value set at that key (isset()) then we return it, otherwise we return false. We're almost ready to feast our eyes on that test page. Let's populate $governors. Wikipedia has the data we're looking for. Copy and paste it into some sort of processor (vim, processed via macros; Google Sheets, processed by function; notepad, processed by masochism) or just share the (probably outdated) fruits of my past labor:

$governors['alabama'] = 'Kay Ivey'; 
$governors['alaska'] = 'Bill Walker'; 
$governors['arizona'] = 'Doug Ducey'; 
$governors['arkansas'] = 'Asa Hutchinson'; 
$governors['california'] = 'Jerry Brown'; 
$governors['colorado'] = 'John Hickenlooper'; 
$governors['connecticut'] = 'Dannel Malloy'; 
$governors['delaware'] = 'John Carney'; 
$governors['florida'] = 'Rick Scott'; 
$governors['georgia'] = 'Nathan Deal'; 
$governors['hawaii'] = 'David Ige'; 
$governors['idaho'] = 'Butch Otter'; 
$governors['illinois'] = 'Bruce Rauner'; 
$governors['indiana'] = 'Eric Holcomb'; 
$governors['iowa'] = 'Kim Reynolds'; 
$governors['kansas'] = 'Jeff Colyer'; 
$governors['kentucky'] = 'Matt Bevin'; 
$governors['louisiana'] = 'John Bel Edwards'; 
$governors['maine'] = 'Paul LePage'; 
$governors['maryland'] = 'Larry Hogan'; 
$governors['massachusetts'] = 'Charlie Baker'; 
$governors['michigan'] = 'Rick Snyder'; 
$governors['minnesota'] = 'Mark Dayton'; 
$governors['mississippi'] = 'Phil Bryant'; 
$governors['missouri'] = 'Eric Greitens'; 
$governors['montana'] = 'Steve Bullock'; 
$governors['nebraska'] = 'Pete Ricketts'; 
$governors['nevada'] = 'Brian Sandoval'; 
$governors['newhampshire'] = 'Chris Sununu'; 
$governors['newjersey'] = 'Phil Murphy'; 
$governors['newmexico'] = 'Susana Martinez'; 
$governors['newyork'] = 'Andrew Cuomo'; 
$governors['northcarolina'] = 'Roy Cooper'; 
$governors['northdakota'] = 'Doug Burgum'; 
$governors['ohio'] = 'John Kasich'; 
$governors['oklahoma'] = 'Mary Fallin'; 
$governors['oregon'] = 'Kate Brown'; 
$governors['pennsylvania'] = 'Tom Wolf'; 
$governors['rhodeisland'] = 'Gina Raimondo'; 
$governors['southcarolina'] = 'Henry McMaster'; 
$governors['southdakota'] = 'Dennis Daugaard'; 
$governors['tennessee'] = 'Bill Haslam'; 
$governors['texas'] = 'Greg Abbott'; 
$governors['utah'] = 'Gary Herbert'; 
$governors['vermont'] = 'Phil Scott'; 
$governors['virginia'] = 'Ralph Northam'; 
$governors['washington'] = 'Jay Inslee'; 
$governors['westvirginia'] = 'Jim Justice'; 
$governors['wisconsin'] = 'Scott Walker'; 

Notice that, to be used as keys, state names were made lowercase and spaces were removed. Let's add a step to do that to our user input in processing:

$input = str_replace(" ", "", strtolower($input)); 

This replaces spaces with nothing (str_replace) makes all letters lower case (strtolower()) and puts that result into the very same $input variable it started as. Now we can finally visit the index.php. By default, it shows only a meager false. Good! That's the expected behavior. Then we'll add ?state=alabama to the URL. Hot Damn, it works!

Slack Bot

So what? No one is going to navigate to that ugly, inconvenient site. We need interface integration. We need to be able to type /governor vermont and get the information back without ever leaving slack. Well, fine. Let's duplicate our HTML interface (index.php) so we can have an interface just for Slack (slack.php). This is where our file_put_contents() is going to come in handy. When we make our SlackBot, we don't expect it to work. But we can examine the failures and modify it until it does. Visiting api.slack.com should direct you to a form that allows you to make your new Slack App. That App will have to live in a Slack workspace that you're a member of.

Making a new Slack App has, frankly, overwhelming options. Ignore them. Make your way to the "Slash Commands" menu item, which will let you configure the command. All you really need is the URL to your slack interface and a command with which to invoke the tool.

Now you can give it a shot in that Slack workspace; try /governor vermont. You'll get a private error, as we expected. What we're more interested in is the new addition to our request.log; A big, juicy, JSON encoded HTTP POST request:

{
  "token":"123456ABCXYZabcxyz987654",
  "team_id":"T34M000ID",
  "team_domain":"teamdomain",
  "channel_id":"12345ABCD",
  "channel_name":"privategroup",
  "user_id":"123456ABC",
  "user_name":"cknowles",
  "command":"\/governor",
  "text":"vermont",
  "response_url":"https:\/\/hooks.slack.com\/commands\/T34M000ID\/123456789012\/ABCXYZabcxyz123456pgImSr",
  "trigger_id":"123456789082.12345678905.1234567890abcdef1234567890abcdef"
} 

I've changed some unimportant details, but you should be able to pick up on two things- Even more clearly if you use a viewer:

  • The argument the user provides (vermont) is stored in $_REQUEST['text']
  • The request comes with a "Response URL", available at $_REQUEST['response_url']

The simplest way to respond to this is probably just to put our response in the HTTP response; echo('stuff'). If we want to do more, though, we'll have to check the Slack docs for what sort of request they'd like us to send them at that response url. Here we see we can send our own request, with a 'text' item, as well as other configurations such as 'response_type'. To use that method, we'll use a curl command to send a request:

$ch = curl_init();
curl_setopt_array(
  $ch,
  array(
    CURLOPT_URL => $_REQUEST['response_url'],
    CURLOPT_POSTFIELDS => $data,
    CURLOPT_SSL_VERIFYPEER => FALSE,
    CURLOPT_SSL_VERIFYHOST => FALSE,
    CURLOPT_POST => TRUE,
    CURLOPT_RETURNTRANSFER => TRUE,
  )
);
$result = curl_exec($ch);
curl_close($ch); 

We'll talk about securing this in a later post. As it stands, an attacker can find your URL endpoint and send GET requests from your server by sending a request_url willy nilly. We'll make sure it's only Slack that can trigger this code. So if we repurpose the code we have in index.php, we can send the correct name. We don't need to rewrite the $governors array or even the code to strip spaces and make state names lower case.

Google Assistant

But no, this is not good enough. We need even MORE convenience. I won't raise a finger- nothing more than my voice. Okay, Google, whaddya got? Same strategy this time:

  • Create an endpoint that logs requests
  • Point the service to the endpoint
  • Examine the request
  • Read the docs for the right response

The basic idea is the same, but the details are a lot fancier this time around. We'll make a new project in the Actions On Google Console (terrible name) and choose to build it with DialogFlow. After we confirm by clicking 'Create', we visit the 'Fulfillment' menu option and point the webhook option to our endpoint. Don't forget to save (it's all the way at the bottom of the page). It's going to be a little trickier this time to invoke the tool and record the response.

Our assistant integration is managed with 'intents'. We will eventually be able to invoke with "Okay Google, talk to Governor Lookup". Google is handling the speech to text and natural language processing and what not, so it's not making a request to us yet. It will trigger the WELCOME event, which triggers whichever intent has WELCOME in their 'Events' area, which happens to be the 'Default Welcome Intent' at the moment. We'll configure the 'Default Welcome Intent' to accept an argument, pass it to our endpoint, and say the response. To do that simulate what a human might say and help Google parse the meaning. An experienced or impatient user might just say:

New Mexico

But it's just as likely that they'll be overly polite:

Excuse me, please tell me who the governor of New Mexico is

Google picks apart the input and tries to guess what we're looking for:

The highlighting pictured indicates that Google has picked up on a variable. Further, it thinks the type of variable is a US State. There are plenty of @sys. codes to choose from, including @sys.any if your input is truly exotic. Moving forward, we can tell Google that a @sys.geo-state-us variable is required, and if the user doesn't provide one then Google needs to go back and ask for it.

Finally, we'll enable webhooks in the 'fulfillment' box and save the intent. There's a (limited) testing pane on the right:

In fact, it's too limited to be useful. We'll click the link it has to 'See how it works in Google Assistant'. This will take us to a more interactive console, but any changes made need to be reloaded. Go to 'Overview' and click 'Test Draft' any time that you make a change.

The 'Request' panel shows a JSON representation of the request. Let's see if it showed up in our request.log:

Hmmm. If it did send that it wasn't in the $_REQUEST super global. Where could it be? It turns out that Google is not actually sending a well formatted request. They're just throwing the string at us. We can catch it with: file_get_contents("php://input"); This time we'll try to keep it simple and just echo our response after grabbing our input from the request. It does have to be formatted according to the docs, though.

<?php 
  include('processing.php'); 
  $request = json_decode(file_get_contents("php://input"), true);
  file_put_contents('request.log',json_encode($request).PHP_EOL,LOCK_EX|FILE_APPEND); 
  $state = $request['result']['parameters']['geo-state-us']; 
  $governor = governorLookup($state); 
  $data['speech'] = 'The governor of '.$state.' is '.$governor; 
  $data['displayText'] = $data['speech']; 
  echo(json_encode($data)); 
?> 

Boom. Beautiful! That's a wrap. Handling errors would be an improvement but it'll have to wait until another post.

Closing Thoughts

Because of the design pattern we chose to go with, governorLookup can be exposed to limitless interfaces. Interfaces behave differently, but fundamentally they ferry information to and fro your endpoints. Each interface merits its own security and optimization considerations, as well as the central function(s) you're exposing.