A Fully Functional Alexa Skill

The following posts are coming up!

Recent Posts



I’ve been talking a lot (a lot) about my Amazon Alexa skill.

First of all, it’s done. It’s published and working. You can check it out on Amazon.com.

Secondly, a lot of people are like me and need a real, concrete, working example. So I’ve decided to post the entirety of my Amazon Alexa Skill.

This file, which is actually found on a site in /lwtv-plugin/rest-api/alexa-skills.php consists of two endpoints: the flash briefing and the skill. The skill is called “Bury Your Queers” and while I suspect that part of the code is the least ‘useful’ to people, it’s also good to see the real code.

Here, then, is the original approved and certified WordPress code for an Alexa Skill:

The Code

<?php
/*
Description: REST-API - Alexa Skills

For Amazon Alexa Skills

Version: 1.0
Author: Mika Epstein
*/

if ( ! defined('WPINC' ) ) die;

/**
 * class LWTV_Alexa_Skills
 *
 * The basic constructor class that will set up our JSON API.
 */
class LWTV_Alexa_Skills {

	/**
	 * Constructor
	 */
	public function __construct() {
		add_action( 'rest_api_init', array( $this, 'rest_api_init') );
	}

	/**
	 * Rest API init
	 *
	 * Creates callbacks
	 *   - /lwtv/v1/flash-briefing
	 */
	public function rest_api_init() {

		// Skills
		register_rest_route( 'lwtv/v1', '/alexa-skills/briefing/', array(
			'methods' => 'GET',
			'callback' => array( $this, 'flash_briefing_rest_api_callback' ),
		) );

		// Skills
		register_rest_route( 'lwtv/v1', '/alexa-skills/byq/', array(
			'methods' => [ 'GET', 'POST' ],
			'callback' => array( $this, 'bury_your_queers_rest_api_callback' ),
		) );


	}

	/**
	 * Rest API Callback for Flash Briefing
	 */
	public function flash_briefing_rest_api_callback( $data ) {
		$response = $this->flash_briefing();
		return $response;
	}

	/**
	 * Rest API Callback for Bury Your Queers
	 * This accepts POST data
	 */
	public function bury_your_queers_rest_api_callback( WP_REST_Request $request ) {

		$type   = ( isset( $request['request']['type'] ) )? $request['request']['type'] : false;
		$intent = ( isset( $request['request']['intent']['name'] ) )? $request['request']['intent']['name'] : false;
		$date   = ( isset( $request['request']['intent']['slots']['Date']['value'] ) )? $request['request']['intent']['slots']['Date']['value'] : false;

		$validate_alexa = $this->alexa_validate_request( $request );

		if ( $validate_alexa['success'] != 1 ) {
			$error = new WP_REST_Response( array( 'message' => $validate_alexa['message'], 'data' => array( 'status' => 400 ) ) );
			$error->set_status( 400 );
			return $error;
		}

		$response = $this->bury_your_queers( $type, $intent, $date );
		return $response;
	}


	function alexa_validate_request( $request ) {

		$chain_url = $request->get_header( 'signaturecertchainurl' );
		$timestamp = $request['request']['timestamp'];
		$signature = $request->get_header( 'signature' );

	    // Validate that it even came from Amazon ...
	    if ( !isset( $chain_url ) )
	    	return array( 'success' => 0, 'message' => 'This request did not come from Amazon.' );

	    // Validate proper format of Amazon provided certificate chain url
	    $valid_uri = $this->alexa_valid_key_chain_uri( $chain_url );
	    if ( $valid_uri != 1 )
	    	return array( 'success' => 0, 'message' => $valid_uri );

	    // Validate certificate signature
	    $valid_cert = $this->alexa_valid_cert( $request, $chain_url, $signature );
	    if ( $valid_cert != 1 )
	    	return array ( 'success' => 0, 'message' => $valid_cert );

	    // Validate time stamp
		if (time() - strtotime( $timestamp ) > 60)
			return array ( 'success' => 0, 'message' => 'Timestamp validation failure. Current time: ' . time() . ' vs. Timestamp: ' . $timestamp );

	    return array( 'success' => 1, 'message' => 'Success' );
	}

	/*
		Validate certificate chain URL
	*/
	function alexa_valid_key_chain_uri( $keychainUri ){

	    $uriParts = parse_url( $keychainUri );

	    if (strcasecmp( $uriParts['host'], 's3.amazonaws.com' ) != 0 )
	        return ( 'The host for the Certificate provided in the header is invalid' );

	    if (strpos( $uriParts['path'], '/echo.api/' ) !== 0 )
	        return ( 'The URL path for the Certificate provided in the header is invalid' );

	    if (strcasecmp( $uriParts['scheme'], 'https' ) != 0 )
	        return ( 'The URL is using an unsupported scheme. Should be https' );

	    if (array_key_exists( 'port', $uriParts ) && $uriParts['port'] != '443' )
	        return ( 'The URL is using an unsupported https port' );

	    return 1;
	}

	/*
	    Validate that the certificate and signature are valid
	*/
	function alexa_valid_cert( $request, $chain_url, $signature ) {

		$md5pem     = get_temp_dir() . md5( $chain_url ) . '.pem';
	    $echoDomain = 'echo-api.amazon.com';

	    // If we haven't received a certificate with this URL before,
	    // store it as a cached copy
	    if ( !file_exists( $md5pem ) ) {
		    file_put_contents( $md5pem, file_get_contents( $chain_url ) );
		}

	    $pem = file_get_contents( $md5pem );

	    // Validate certificate chain and signature
	    $ssl_check = openssl_verify( $request->get_body() , base64_decode( $signature ), $pem, 'sha1' );

	    if ($ssl_check != 1 ) {
		    return( openssl_error_string() );
		}

	    // Parse certificate for validations below
	    $parsedCertificate = openssl_x509_parse( $pem );
	    if ( !$parsedCertificate ) return( 'x509 parsing failed' );

	    // Check that the domain echo-api.amazon.com is present in
	    // the Subject Alternative Names (SANs) section of the signing certificate
	    if(strpos( $parsedCertificate['extensions']['subjectAltName'], $echoDomain) === false) {
	        return( 'subjectAltName Check Failed' );
	    }

	    // Check that the signing certificate has not expired
	    // (examine both the Not Before and Not After dates)
	    $validFrom = $parsedCertificate['validFrom_time_t'];
	    $validTo   = $parsedCertificate['validTo_time_t'];
	    $time      = time();

	    if ( !( $validFrom <= $time && $time <= $validTo ) ) {
	        return( 'certificate expiration check failed' );
	    }

	    return 1;
	}

	/**
	 * Generate the Flash Briefing output
	 *
	 * @access public
	 * @return void
	 */
	public function flash_briefing() {

		$query = new WP_Query( array( 'numberposts' => '10' ) );
		if ( $query->have_posts() ) {
			while ( $query->have_posts() ) {
				$query->the_post();

				$response = array(
					'uid'            => get_the_permalink(),
					'updateDate'     => get_post_modified_time( 'Y-m-d\TH:i:s.\0\Z' ),
					'titleText'      => get_the_title(),
					'mainText'       => get_the_title() . '. ' . get_the_excerpt(),
					'redirectionUrl' => home_url(),
				);

				$responses[] = $response;
			}
			wp_reset_postdata();
		}

		if ( count( $responses ) === 1 ) {
			$responses = $responses[0];
		}

		return $responses;

	}

	/**
	 * Generate Bury Your Queers
	 *
	 * @access public
	 * @return void
	 */
	public function bury_your_queers( $type = false, $intent = false, $date = false ) {

		$whodied    = '';
		$endsession = true;
		$timestamp  = ( strtotime( $date ) == false )? false : strtotime( $date ) ;
		$helptext   = 'You can find out who died on specific dates by asking me questions like "who died" or "who died today" or "who died on March 3rd" or even "How many died in 2017." If no one died then, I\'ll let you know.';

		if ( $type == 'LaunchRequest' ) {
			$whodied = 'Welcome to the LezWatch TV Bury Your Queers skill. ' . $helptext;
			$endsession = false;
		} else {
			if ( $intent == 'AMAZON.HelpIntent' ) {
				$whodied = 'This is the Bury Your Queers skill by LezWatch TV, home of the world\'s greatest database of queer female on TV. ' . $helptext;
				$endsession = false;
			} elseif ( $intent == 'AMAZON.StopIntent' || $intent == 'AMAZON.CancelIntent' ) {
				// Do nothing
			} elseif ( $intent == 'HowMany' ) {
				if ( $date == false || $timestamp == false ) {
					$data     = LWTV_Stats_JSON::statistics( 'death', 'simple' );
					$whodied  = 'A total of '. $data['characters']['dead'] .' queer female characters have died on TV.';
				} elseif ( !preg_match( '/^[0-9]{4}$/' , $date ) ) {
					$whodied    = 'I\'m sorry. I don\'t know how to calculate deaths in anything but years right now. ' . $helptext;
					$endsession = false;
				} else {
					$data     = LWTV_Stats_JSON::statistics( 'death', 'years' );
					$count    = $data[$date]['count'];
					$how_many = 'No queer female characters died on TV in ' . $date . '.';
					if ( $count > 0 ) {
						$how_many = $count .' queer female ' . _n( 'character', 'characters', $count ) . ' died on TV in ' . $date . '.';
					}
					$whodied  = $how_many;
				}
			} elseif ( $intent == 'WhoDied' ) {
				if ( $date == false || $timestamp == false ) {
					$data    = LWTV_BYQ_JSON::last_death();
					$name    = $data['name'];
					$whodied = 'The last queer female to die was '. $name .' on '. date( 'F j, Y', $data['died'] ) .'.';
				} elseif ( preg_match( '/^[0-9]{4}-(0[1-9]|1[0-2])$/' , $date ) ) {
					$whodied    = 'I\'m sorry. I don\'t know how to calculate deaths in anything but days right now. ' . $helptext;
					$endsession = false;
				} else {
					$this_day = date('m-d', $timestamp );
					$data     = LWTV_BYQ_JSON::on_this_day( $this_day );
					$count    = ( key( $data ) == 'none' )? 0 : count( $data ) ;
					$how_many = 'No queer females died';
					$the_dead = '';
					if ( $count > 0 ) {
						$how_many  = $count . ' queer female ' . _n( 'character', 'characters', $count ) . ' died';
						$deadcount = 1;
						foreach ( $data as $dead_character ) {
							if ( $deadcount == $count && $count !== 1 ) $the_dead .= 'And ';
							$the_dead .= $dead_character['name'] . ' in ' . $dead_character['died'] . '. ';
							$deadcount++;
						}
					}
					$whodied = $how_many . ' on '. date('F jS', $timestamp ) . '. ' . $the_dead;
				}
			} else {
				// We have a weird request...
				$whodied = 'I\'m sorry, I don\'t understand that request. Please ask me something else.';
				$endsession = false;
			}
		}
		$response = array(
			'version'  => '1.0',
			'response' => array (
				'outputSpeech' => array (
					'type' => 'PlainText',
					'text' => $whodied,
				),
				'shouldEndSession' => $endsession,
			)
		);

		return $response;

	}

}
new LWTV_Alexa_Skills();

Some Explanations

The functions bury_your_queers and bury_your_queers_rest_api_callback are the important ones. The flash briefing is there because I was tired of Amazon being picky about embedded media in RSS feeds.

The way bury_your_queers_rest_api_callback works is it takes the request data to generate the type of request, the intent, and the date information. Then it passes the full request data to alexa_validate_request which is the part you’ll really want.

That function, alexa_validate_request, is what’s validating that the request came from Amazon, that it’s got a legit certificate from Amazon, and that the request was made in the last 60 seconds. While all those checks kick back an error, the development tools from Amazon will not show you them. Yet. I’m hoping they will in the future so we can more easily debug, but it was a lot of blind debugging. Not my favorite.

Some Custom Code

In the bury_your_queers function, I make some calls to other code not included:

  • LWTV_BYQ_JSON::last_death()
  • LWTV_BYQ_JSON::on_this_day( $this_day )

Those both reference another rest API class in a different file. What’s important here is not what the data is, but that I’m calling those functions and getting an array back, and using that to fill in my reply. For example, here we have the call for ‘last death’:

$data    = LWTV_BYQ_JSON::last_death();
$name    = $data['name'];
$whodied = 'The last queer female to die was '. $name .' on '. date( 'F j, Y', $data['died'] ) .'.';

From this you can infer that the array kicked back has key for name and died. And in fact, if you look at the JSON output, you’ll see if has that and a bit more. I’m just extracting what is required. The same is true of the other function, LWTV_BYQ_JSON::on_this_day, to which I’m passing a parameter of a date.


Posted

in

by

Comments

10 responses to “A Fully Functional Alexa Skill”

  1. Steve Silver Avatar
    Steve Silver

    This is very cool! Did I see you mention that you had this on GitHub?

    1. Ipstenu (Mika Epstein) Avatar

      @Steve Silver: No this isn’t on Github, but it is on a fully self-hosted Git install. It’s customized for a one-off site and I’ve not yet figure out how to host it on Github without all that background data. Which I think is the ultimate issue of writing any Alexa skill. It’s always so unique.

    2. steve silver Avatar
      steve silver

      @Ipstenu (Mika Epstein):

      Ahh Git install…sorry my brain read wrong.. Again this is a great skill. If you have questions I highly recommend signing up for Amazon’s weekly office hours..the dev team is great… I know they would want to learn more about your skill as there has been talk about how to turn on the wordpress community to Alexa..

      Here is the link to the office hours sign up page…
      https://goo.gl/dHv9WY

      Steve

    3. Ipstenu (Mika Epstein) Avatar

      @steve silver: I’ll check that out. I think the biggest holdups are:

      1. Use Cases. Without someone seeing a good use-case, like ordering from their eCommerce store, no one’s going to make the apps.
      2. No PHP SDK. Seriously what the hell? If you want WP, make an OFFICIAL Amazon Echo SDK for PHP. 99% of what I had to do for my plugin to work was bloody well invent the php code to meet their requirements. Which brings me to…

      3. Shitty documentation. I don’t know how else to put this, but Amazon’s docs SUCK. You have to read through everything in a non-linear way to figure out what’s required for you to be certified. And the error trapping of what you did wrong is a joke. It just says there’s an error and not anything like what it was.

      Also they don’t seem to understand the issue I reported. The tl;dr version is that if you add an RSS feed to daily-news, and you embed video, their validator seems to think the video has to have an audio tag, even if it uses the html5 video tags. It’s really messed up.

    4. steve silver Avatar
      steve silver

      Use cases.. With millions of WP sites, there will be a percentage (what that number is I don’t know yet) that will want to have a voice skill to drive news through.
      An example is The Enthusiast Network (enthusiastnetwork.com) which is a group that publishes magazines. All of their sites sit on top of WP. As a consumer driven site they want to be able to offer their news out to all platforms this includes Alexa Voice Skills, Google Home Actions, Microsoft Cortana Skills. It is not about e-commerce for this group.
      The idea for them is if you can integrate an Alexa skill into an already established workflow such as WP then it makes managing an active Alexa Skill all the easier.

      There are then the consumers that have the hobby WP site..that want to show off and have a voice skill.. Since Amazon does not charge for publishing or hosting a skill consumers can play..

      I see three use cases for Word Press to Voice…
      1. the hobbyist…
      2. small to medium business that wants to be able to provide a new way to promote their business
      3. Large sites – Entertainment, Publishing that has a site they currently program (content) on an hourly, daily basis and by having a way to go between WP and Alexa they don’t duplicate their workflow.

      Yes, I agree with you the documentation is bad bad bad.. I taught myself how to code Alexa skills using their docs and it was painful.
      Also, agree on the lack of PHP SDK. Inventing the code puts you in a spot to capitalize on this… Companies like Amazon, Microsoft, Google…go for the easy way in and eventually move in on the harder gets such PHP SDK.

      The issue do you only mention on the blog? Did you pass this along to anyone from the Dev team at Amazon? I can introduce you to David Isbitski the Lead Evangelist for Alexa if you want to pass the issue along.

    5. Ipstenu (Mika Epstein) Avatar

      I sent in a report and the reply was pretty shitty.

    6. steve silver Avatar
      steve silver

      @Ipstenu (Mika Epstein): @Ipstenu (Mika Epstein):

      Can I via email introduce you to the Amazon BD rep that works with devs? There are two one East and the other West Coast. I would love to get them to escalate? Please do reach out to my email address which I believe you can see if you are interested.

    7. Ipstenu (Mika Epstein) Avatar

      Apparently I finally got through to them what the problem was, so this crisis is averted! Thank you though. If I get into a pickle again, I’ll let you know.

    8. Steve Silver Avatar
      Steve Silver

      I want to try and implement using Amazon AWS hosting the JSON? You mention there are other files you host besides the JSON? Can we take the discussion to email?

    9. Ipstenu (Mika Epstein) Avatar

      The other files are the code that generates the data the JSON uses. Basically I’d written a json powered plugin and then reused the code to power the Echo part. The important bit is getting your data to output JSON in the Amazon Way.

%d bloggers like this: