├── podcasts.json ├── .gitignore ├── .eslintrc.json ├── serverless.yml ├── index.js ├── package.json ├── LICENSE.md ├── test ├── latest-episode.json └── play-latest-episode.json ├── README.md ├── audioEventHandlers.js ├── intents.json └── handlers.js /podcasts.json: -------------------------------------------------------------------------------- 1 | { 2 | "dynamic banter": "https://rss.art19.com/dynamic-banter" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "rules": { 4 | "indent": ["error", 4], 5 | "max-len": ["error", 120, 4] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: podcasts 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs6.10 6 | profile: serverless 7 | region: us-west-2 8 | 9 | functions: 10 | podcast: 11 | handler: index.handler 12 | events: 13 | - alexaSkill -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const Alexa = require('alexa-sdk'); 2 | const audioEventHandlers = require('./audioEventHandlers'); 3 | const handlers = require('./handlers'); 4 | 5 | exports.handler = (event, context, callback) => { 6 | console.log('\n' + JSON.stringify(event, null, 2)); 7 | 8 | const alexa = Alexa.handler(event, context, callback); 9 | 10 | // Replace with your appId 11 | alexa.appId = 'amzn1.ask.skill.123'; 12 | 13 | alexa.registerHandlers(handlers, audioEventHandlers); 14 | alexa.execute(); 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alexa-podcasts", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Andres Rodriguez", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "eslint-config-standard": "^10.2.1", 13 | "eslint-plugin-import": "^2.8.0", 14 | "eslint-plugin-node": "^5.2.1", 15 | "eslint-plugin-promise": "^3.6.0", 16 | "eslint-plugin-standard": "^3.0.1" 17 | }, 18 | "dependencies": { 19 | "alexa-sdk": "^1.0.19", 20 | "rss-parser": "^2.10.8" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Andres Rodriguez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /test/latest-episode.json: -------------------------------------------------------------------------------- 1 | { 2 | "session": { 3 | "new": true, 4 | "sessionId": "SessionId.21bfb130-77b9-4875-9841-cace7eaf5ecb", 5 | "application": { 6 | "applicationId": "amzn1.ask.skill.123" 7 | }, 8 | "attributes": {}, 9 | "user": { 10 | "userId": "amzn1.ask.account.userId" 11 | } 12 | }, 13 | "request": { 14 | "type": "IntentRequest", 15 | "requestId": "EdwRequestId.5a39ce7d-a790-4088-af0a-40ac69224668", 16 | "intent": { 17 | "name": "LatestPodcast", 18 | "slots": { 19 | "PodcastName": { 20 | "name": "PodcastName", 21 | "value": "dynamic banter" 22 | } 23 | } 24 | }, 25 | "locale": "en-US", 26 | "timestamp": "2017-11-10T23:25:38Z" 27 | }, 28 | "context": { 29 | "AudioPlayer": { 30 | "playerActivity": "IDLE" 31 | }, 32 | "System": { 33 | "application": { 34 | "applicationId": "amzn1.ask.skill.123" 35 | }, 36 | "user": { 37 | "userId": "amzn1.ask.account.userId" 38 | }, 39 | "device": { 40 | "supportedInterfaces": {} 41 | } 42 | } 43 | }, 44 | "version": "1.0" 45 | } 46 | -------------------------------------------------------------------------------- /test/play-latest-episode.json: -------------------------------------------------------------------------------- 1 | { 2 | "session": { 3 | "new": true, 4 | "sessionId": "SessionId.6d27afec-918d-4240-874d-ec16ed62cc0c", 5 | "application": { 6 | "applicationId": "amzn1.ask.skill.123" 7 | }, 8 | "attributes": {}, 9 | "user": { 10 | "userId": "amzn1.ask.account.userId" 11 | } 12 | }, 13 | "request": { 14 | "type": "IntentRequest", 15 | "requestId": "EdwRequestId.5ab27361-2596-470d-a5f5-3877f9aed3a4", 16 | "intent": { 17 | "name": "PlayLatestPodcast", 18 | "slots": { 19 | "PodcastName": { 20 | "name": "PodcastName", 21 | "value": "dynamic banter" 22 | } 23 | } 24 | }, 25 | "locale": "en-US", 26 | "timestamp": "2017-11-11T01:44:57Z" 27 | }, 28 | "context": { 29 | "AudioPlayer": { 30 | "playerActivity": "IDLE" 31 | }, 32 | "System": { 33 | "application": { 34 | "applicationId": "amzn1.ask.skill.123" 35 | }, 36 | "user": { 37 | "userId": "amzn1.ask.account.userId" 38 | }, 39 | "device": { 40 | "supportedInterfaces": {} 41 | } 42 | } 43 | }, 44 | "version": "1.0" 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Basic Podcast Player Alexa Skill 2 | 3 | This skill teaches Alexa how to get latest episode information from a pre-configured list of podcasts. The example implementation integrates with the following podcast: https://headgum.com/dynamic-banter. The file `podcasts.json` defines a mapping between podcast names and RSS feed URLs. 4 | 5 | This repo assumes you already know how to setup an Alexa skill and connect it to AWS Lambda. For more information, check the following two repos: 6 | 7 | - https://github.com/alexa/alexa-skills-kit-sdk-for-nodejs 8 | - https://github.com/alexa/skill-sample-nodejs-audio-player 9 | 10 | `intents.json` holds the definition of the skill and intents you need to create on the Amazon developer portal. Make sure you enable the Audio Player directive for your skill. 11 | 12 | ## Intents 13 | 14 | ### LatestPodcast 15 | 16 | Gets information about the latest episode of a podcast. Sample utterances: 17 | 18 | "give me the latest episode from {PodcastName}" 19 | "tell me about the latest episode from {PodcastName}" 20 | 21 | ### PlayLatestPodcast 22 | 23 | Plays the latest episode of a podcast. Sample utterances: 24 | 25 | "play {PodcastName}" 26 | "play the latest episode of {PodcastName}" 27 | 28 | ## Requirements 29 | 30 | - An Alexa enabled device (Echo, etc.) 31 | - AWS Lambda 32 | - Serverless (https://serverless.com/framework/docs/getting-started/) 33 | 34 | ## How to run and test 35 | 36 | ``` 37 | npm install 38 | 39 | # First deployment: 40 | serverless deploy -v 41 | 42 | # On code changes: 43 | serverless deploy -f podcast node 44 | 45 | # Testing locally: 46 | serverless invoke local -f podcast -p test/latest-episode.json 47 | serverless invoke local -f podcast -p test/play-latest-episode.json 48 | ``` 49 | 50 | -------------------------------------------------------------------------------- /audioEventHandlers.js: -------------------------------------------------------------------------------- 1 | var audioEventHandlers = { 2 | PlaybackStarted: function() { 3 | /* 4 | * AudioPlayer.PlaybackStarted Directive received. 5 | * Confirming that requested audio file began playing. 6 | * Do not send any specific response. 7 | */ 8 | console.log('Playback started'); 9 | this.emit(':responseReady'); 10 | }, 11 | PlaybackFinished: function() { 12 | /* 13 | * AudioPlayer.PlaybackFinished Directive received. 14 | * Confirming that audio file completed playing. 15 | * Do not send any specific response. 16 | */ 17 | console.log('Playback finished'); 18 | this.emit(':responseReady'); 19 | }, 20 | PlaybackStopped: function() { 21 | /* 22 | * AudioPlayer.PlaybackStopped Directive received. 23 | * Confirming that audio file stopped playing. 24 | */ 25 | console.log('Playback stopped'); 26 | 27 | //do not return a response, as per https://developer.amazon.com/docs/custom-skills/audioplayer-interface-reference.html#playbackstopped 28 | this.emit(':responseReady'); 29 | }, 30 | PlaybackNearlyFinished: function() { 31 | /* 32 | * AudioPlayer.PlaybackNearlyFinished Directive received. 33 | * Replacing queue with the URL again. 34 | * This should not happen on live streams 35 | */ 36 | console.log('Playback nearly finished'); 37 | this.emit(':responseReady'); 38 | }, 39 | PlaybackFailed: function() { 40 | /* 41 | * AudioPlayer.PlaybackFailed Directive received. 42 | * Logging the error and restarting playing. 43 | */ 44 | console.log('Playback Failed : %j', this.event.request.error); 45 | this.response.audioPlayerClearQueue('CLEAR_ENQUEUED'); 46 | this.emit(':responseReady'); 47 | } 48 | }; 49 | 50 | module.exports = audioEventHandlers; 51 | -------------------------------------------------------------------------------- /intents.json: -------------------------------------------------------------------------------- 1 | { 2 | "languageModel": { 3 | "types": [ 4 | { 5 | "name": "PodcastName", 6 | "values": [ 7 | { 8 | "id": null, 9 | "name": { 10 | "value": "dynamic banter", 11 | "synonyms": [] 12 | } 13 | } 14 | ] 15 | } 16 | ], 17 | "intents": [ 18 | { 19 | "name": "AMAZON.CancelIntent", 20 | "samples": [] 21 | }, 22 | { 23 | "name": "AMAZON.HelpIntent", 24 | "samples": [] 25 | }, 26 | { 27 | "name": "AMAZON.PauseIntent", 28 | "samples": [] 29 | }, 30 | { 31 | "name": "AMAZON.ResumeIntent", 32 | "samples": [] 33 | }, 34 | { 35 | "name": "AMAZON.StopIntent", 36 | "samples": [] 37 | }, 38 | { 39 | "name": "LatestPodcast", 40 | "samples": [ 41 | "what's the latest episode from {PodcastName}", 42 | "tell me about the latest episode from {PodcastName}", 43 | "give me the latest episode from {PodcastName}" 44 | ], 45 | "slots": [ 46 | { 47 | "name": "PodcastName", 48 | "type": "PodcastName" 49 | } 50 | ] 51 | }, 52 | { 53 | "name": "PlayLatestPodcast", 54 | "samples": [ 55 | "play the latest episode of {PodcastName}", 56 | "play {PodcastName}" 57 | ], 58 | "slots": [ 59 | { 60 | "name": "PodcastName", 61 | "type": "PodcastName" 62 | } 63 | ] 64 | } 65 | ], 66 | "invocationName": "podcasts" 67 | }, 68 | "prompts": [ 69 | { 70 | "id": "Elicit.Intent-LatestPodcast.IntentSlot-PodcastName", 71 | "variations": [ 72 | { 73 | "type": "PlainText", 74 | "value": "what's the name of the podcast" 75 | }, 76 | { 77 | "type": "PlainText", 78 | "value": "tell me the name of your podcast" 79 | } 80 | ] 81 | }, 82 | { 83 | "id": "Elicit.Intent-PlayLatestPodcast.IntentSlot-PodcastName", 84 | "variations": [ 85 | { 86 | "type": "PlainText", 87 | "value": "what is the name of the podcast" 88 | }, 89 | { 90 | "type": "PlainText", 91 | "value": "what podcast do you want to listen" 92 | } 93 | ] 94 | } 95 | ], 96 | "dialog": { 97 | "intents": [ 98 | { 99 | "name": "LatestPodcast", 100 | "confirmationRequired": false, 101 | "prompts": {}, 102 | "slots": [ 103 | { 104 | "name": "PodcastName", 105 | "type": "PodcastName", 106 | "elicitationRequired": true, 107 | "confirmationRequired": false, 108 | "prompts": { 109 | "elicitation": 110 | "Elicit.Intent-LatestPodcast.IntentSlot-PodcastName" 111 | } 112 | } 113 | ] 114 | }, 115 | { 116 | "name": "PlayLatestPodcast", 117 | "confirmationRequired": false, 118 | "prompts": {}, 119 | "slots": [ 120 | { 121 | "name": "PodcastName", 122 | "type": "PodcastName", 123 | "elicitationRequired": true, 124 | "confirmationRequired": false, 125 | "prompts": { 126 | "elicitation": 127 | "Elicit.Intent-PlayLatestPodcast.IntentSlot-PodcastName" 128 | } 129 | } 130 | ] 131 | } 132 | ] 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /handlers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This skill only supports two intents: 3 | * 4 | * - LatestPodcast: get information about the latest episode of a podcast 5 | * - PlayLatestPodcast: play the latest episode of a podcast 6 | * 7 | * The podcasts file holds the dictionary of all podcasts that Alexa should 8 | * know about. 9 | */ 10 | const parser = require('rss-parser'); 11 | const podcasts = require('./podcasts.json'); 12 | 13 | // Taken from: https://twitter.com/rauchg/status/712799807073419264?lang=en 14 | const leftPad = (v, n, c = '0') => 15 | String(v).length >= n ? '' + v : (String(c).repeat(n) + v).slice(-n); 16 | 17 | /** 18 | * Retrieves the latest episode of a podcast 19 | * 20 | * @param {string} rssURL - URL of the RSS feed 21 | * @param {function} callback - callback(err, latestEpisode) 22 | */ 23 | const getLatestEpisode = (rssURL, callback) => { 24 | parser.parseURL(rssURL, (err, parsed) => 25 | callback(err, parsed && parsed.feed.entries[0]) 26 | ); 27 | }; 28 | 29 | /** 30 | * Creates a text response for the LatestPodcast intent 31 | * 32 | * @param {string} podcastName - Name of the podcast 33 | * @param {string} episode - Episode object 34 | */ 35 | const episodeResponse = (podcastName, episode) => { 36 | let pubDate = new Date(episode.pubDate); 37 | 38 | pubDate = 39 | leftPad(pubDate.getMonth() + 1, 2) + leftPad(pubDate.getDate() + 1, 2); 40 | const pubDateText = `????${pubDate}`; 41 | 42 | return `The latest episode from ${podcastName} is titled: ${episode.title}. 43 | The description says: ${episode.contentSnippet 44 | .trim() 45 | .replace( 46 | /[|&;$%@"<>()+,]/g, 47 | '' 48 | )}. It was released on ${pubDateText}`.replace(/\n/gm, ''); 49 | }; 50 | 51 | /** 52 | * LatestPodcast intent handler 53 | * 54 | * Emits a ':tell' response 55 | */ 56 | function latestIntent() { 57 | const podcastName = this.event.request.intent.slots.PodcastName.value; 58 | const rssURL = podcasts[podcastName]; 59 | 60 | if (!rssURL) { 61 | return this.emit(':tell', "I don't know about that podcast"); 62 | } 63 | 64 | getLatestEpisode(rssURL, (err, episode) => { 65 | if (err) { 66 | console.log(err.message); 67 | return this.emit(':tell', "I'm sorry. Something went wrong."); 68 | } 69 | 70 | return this.emit(':tell', episodeResponse(podcastName, episode)); 71 | }); 72 | } 73 | 74 | /** 75 | * PlayLatestPodcast intent handler 76 | * 77 | * Emits a ':tell' resposne and returns a directive 78 | * to start playing audio from a URL 79 | */ 80 | function playIntent() { 81 | const podcastName = this.event.request.intent.slots.PodcastName.value; 82 | const rssURL = podcasts[podcastName]; 83 | 84 | if (!rssURL) { 85 | return this.emit(':tell', "I don't know about that podcast"); 86 | } 87 | 88 | getLatestEpisode(rssURL, (err, episode) => { 89 | if (err) { 90 | console.log(err.message); 91 | return this.emit(':tell', "I'm sorry. Something went wrong."); 92 | } 93 | 94 | this.response 95 | .speak(`Playing the latest episode of ${podcastName}`) 96 | .audioPlayerPlay( 97 | 'REPLACE_ALL', 98 | episode.enclosure.url.replace('http', 'https'), // hack, 99 | episode.enclosure.url.replace('http', 'https'), 100 | null, 101 | 0 102 | ); 103 | return this.emit(':responseReady'); 104 | }); 105 | } 106 | 107 | /** 108 | * Tells Alexa to stop playing audio 109 | */ 110 | function stopIntent() { 111 | this.response.speak('Bye bye.').audioPlayerStop(); 112 | this.emit(':responseReady'); 113 | } 114 | 115 | /** 116 | * Intent -> Handlers definition 117 | */ 118 | module.exports = { 119 | LatestPodcast: latestIntent, 120 | PlayLatestPodcast: playIntent, 121 | 'AMAZON.PauseIntent': stopIntent, 122 | 'AMAZON.ResumeIntent': playIntent, 123 | 'AMAZON.CancelIntent': stopIntent, 124 | 'AMAZON.StopIntent': stopIntent, 125 | SessionEndedRequest: function() { 126 | console.log('Session ended'); 127 | }, 128 | ExceptionEncountered: function() { 129 | console.log('\n******************* EXCEPTION **********************'); 130 | console.log('\n' + JSON.stringify(this.event.request, null, 2)); 131 | this.callback(null, null); 132 | }, 133 | Unhandled: function() { 134 | this.response.speak( 135 | "Sorry, I could not understand what you've just said." 136 | ); 137 | this.emit(':responseReady'); 138 | } 139 | }; 140 | --------------------------------------------------------------------------------