├── .babelrc
├── .eslintignore
├── .eslintrc.js
├── .gitattributes
├── .gitignore
├── .npmignore
├── CHANGELOG.md
├── LICENSE.txt
├── README.md
├── package.json
├── quickstart.md
├── scripts
├── bump-package-version.js
└── release.sh
├── src
├── index.js
├── utils
│ ├── build_handlers.js
│ ├── build_params.js
│ ├── copy_except.js
│ └── is_of_type.js
└── version.js
├── test
├── index.test.js
└── utils
│ ├── build_handlers.test.js
│ ├── build_params.test.js
│ └── is_of_type.test.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["latest"]
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | lib/
2 | src/version.js
3 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "extends": "algolia",
3 | "rules": {
4 | "space-before-blocks": ["error"],
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text eol=lf
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .eslintcache
3 | npm-debug.log
4 | lib/*
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .babelrc
2 | .eslintignore
3 | .eslintrc.js
4 | src/
5 | test/
6 | quickstart.md
7 | !lib/
8 | !lib/utils/
9 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 |
2 | ## [1.1.3](https://github.com/algolia/algoliasearch-alexa-adapter/compare/v1.1.2...v1.1.3) (2017-09-12)
3 |
4 |
5 |
6 |
7 | ## [1.1.2](https://github.com/algolia/algoliasearch-alexa-adapter/compare/v1.1.1...v1.1.2) (2017-09-04)
8 |
9 |
10 | ### Bug Fixes
11 |
12 | * **build_params:** Stop overriding the original params object ([#21](https://github.com/algolia/algoliasearch-alexa-adapter/issues/21)) ([4ffbaaf](https://github.com/algolia/algoliasearch-alexa-adapter/commit/4ffbaaf))
13 |
14 |
15 |
16 |
17 | ## [1.1.1](https://github.com/algolia/algoliasearch-alexa-adapter/compare/v1.0.0...v1.1.1) (2017-08-23)
18 |
19 |
20 |
21 |
22 | # [1.1.0](https://github.com/algolia/algoliasearch-alexa-adapter/compare/v1.0.0...v1.1.0) (2017-08-14)
23 |
24 |
25 |
26 |
27 | # [1.0.0](https://github.com/algolia/algoliasearch-alexa-adapter/compare/v0.4.1...v1.0.0) (2017-08-02)
28 |
29 |
30 |
31 |
32 | ## [0.4.1](https://github.com/algolia/algoliasearch-alexa-adapter/compare/v0.4.0...v0.4.1) (2017-08-02)
33 |
34 |
35 |
36 |
37 | # [0.4.0](https://github.com/algolia/algoliasearch-alexa-adapter/compare/v0.3.2...v0.4.0) (2017-07-27)
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Algolia
4 | http://www.algolia.com/
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in
14 | all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
4 |
5 | - [Algolia Alexa Skills Kit Adapter](#algolia-alexa-skills-kit-adapter)
6 | - [Quick start guide](#quick-start-guide)
7 | - [API Description](#api-description)
8 | - [algoliaAlexaAdapter](#algoliaalexaadapter)
9 | - [Handlers Configuration](#handlers-configuration)
10 | - [Without Querying Algolia](#without-querying-algolia)
11 | - [Querying Algolia](#querying-algolia)
12 | - [State Management](#state-management)
13 | - [Localization](#localization)
14 | - [Dev](#dev)
15 | - [Linting](#linting)
16 |
17 |
18 |
19 | # Algolia Alexa Skills Kit Adapter
20 |
21 | This is an adapter that allows you to use the Algolia search API easily within your Alexa Skills Kit Skill. It provides tools for integrating Algolia search and a framework for structuring your Alexa skill.
22 |
23 | Developed to be used on Amazon Lambda, you set up your intent handlers normally except for any that you want to leverage Algolia. For these handlers, a configuration object must be provided that defines the handler you want to call upon completion of the Algolia search. Algolia will be queried automatically, then provide an object with the results, intent, session, and response to your defined handler.
24 |
25 | Here you can see an example usage:
26 |
27 | ```javascript
28 | const algoliaAlexaAdapter = require('algoliasearch-alexa').default;
29 |
30 | const handlers = {
31 | LaunchRequest () {
32 | this.emit(':tell', 'Welcome to the skill!');
33 | },
34 | SearchProductIntent: {
35 | answerWith (data) {
36 | if(data.results.nbHits) {
37 | this.emit(':tell', `There were ${data.results.nbHits} products found.`);
38 | } else {
39 | this.emit(':tell', 'We could find no products. Please try again.');
40 | }
41 | },
42 | params: {
43 | hitsPerPage: 1,
44 | filters (requestBody) {
45 | return `brand:${requestBody.request.intent.slots.brand.value}`;
46 | }
47 | },
48 | },
49 | CustomHelpIntent () {
50 | const speechOutput = 'Find one of 10,000 products from the Product Store, powered by Algolia.';
51 | this.emit(':tell', speechOutput);
52 | },
53 | Unhandled () {
54 | this.emit(':tell', 'Look for products in the Product Store.');
55 | },
56 | };
57 |
58 | const voiceSearch = algoliaAlexaAdapter({
59 | algoliaAppId: 'applicationId',
60 | algoliaApiKey: 'publicSearchKey',
61 | defaultIndexName: 'products',
62 | alexaAppId: 'amzn1.echo-sdk-ams.app.[unique-value-here]',
63 | handlers,
64 | });
65 |
66 | module.exports = voiceSearch;
67 | ```
68 |
69 | ## Quick start guide
70 |
71 | Follow [this guide](quickstart.md) to quickly start with Algolia and Alexa.
72 |
73 | ## API Description
74 |
75 | ### algoliaAlexaAdapter
76 |
77 | This function accepts a single argument, which is a configuration object.
78 |
79 | This configuration object accepts:
80 | - `algoliaAppId`: The app ID from your Algolia application **(required)**
81 | - `algoliaApiKey`: The public search key associated with your Algolia application **(required)**
82 | - `alexaAppId`: Used to verify that the request is coming from your Alexa Skill, responding with an error if defined and requesting application does not match this ID; optional but recommended
83 | - `defaultIndexName`: The index you want to query on Algolia **(required)**
84 | - `handlers`: An object with your standard request (`LaunchRequest`, `IntentRequest`, or `SessionEndedRequest`) or built-in and intent handlers **(required)**
85 |
86 | #### Handlers Configuration
87 |
88 | Each handler can be configured in one of two ways. How it's configured depends on whether one wants to query Algolia first or not.
89 |
90 | ##### Without Querying Algolia
91 |
92 | Specify a key-value pair where the key is the intent handler name and the value is a function. The function will accept no arguments, but has the current request information bound to `this`, provided by the Alexa service via Lambda.
93 |
94 | ##### Querying Algolia
95 |
96 | Specify a key-value pair where the key is the intent handler name and the value is an object. That object contains a function `answerWith` which will be invoked following the Algolia search. This accepts one argument: an object with values for the keys of `results` from Algolia and `event` from the Alexa service.
97 |
98 | #### State Management
99 |
100 | States in the Alexa Skills Kit represent, roughly, different steps in the skill flow process. For example, there can be a state for starting a game, a state for being in the middle of a turn, and an empty state that represents the skill launch. You can [read more here](https://github.com/alexa/alexa-skills-kit-sdk-for-nodejs#making-skill-state-management-simpler) at the Alexa Skills Kit SDK README.
101 |
102 | To define your states for each handler, provide an array of objects, with each that you want tied to a specific state to have a key of `state`:
103 |
104 | ```javascript
105 | const states = {
106 | SEARCHINGMODE: '_SEARCHINGMODE'
107 | };
108 |
109 | const handlers = [
110 | {
111 | NewSession () {
112 | this.handler.state = states.SEARCHINGMODE;
113 | this.emit(':ask', 'Welcome to the skill! What product would you like to find?');
114 | },
115 | }, {
116 | state: states.SEARCHINGMODE,
117 | 'AMAZON.YesIntent': {
118 | answerWith (data) {
119 | // Do something...
120 | }
121 | }
122 | }
123 | ];
124 | ```
125 |
126 | #### Localization
127 |
128 | You can set your localization strings via the `languageStrings` option on the top level object. Within the intents, you will invoke them with `this.t` as normal. [See here for more information](https://github.com/alexa/alexa-skills-kit-sdk-for-nodejs#adding-multi-language-support-for-skill) on localizing a skill.
129 |
130 | ## Dev
131 |
132 | ```
133 | $ npm run dev
134 | ```
135 |
136 | ## Linting
137 |
138 | Lints using eslint:
139 |
140 | ```
141 | $ npm run lint
142 | ```
143 |
144 | Autofixer:
145 |
146 | ```
147 | $ npm run lint:fix
148 | ```
149 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "algoliasearch-alexa",
3 | "version": "1.1.3",
4 | "description": "AlgoliaSearch Alexa Skills Kit Adapter",
5 | "main": "lib/index.js",
6 | "browser": {},
7 | "scripts": {
8 | "build": "babel src -d lib",
9 | "lint": "if [ \"$CI\" = \"true\" ]; then eslint .; else eslint . --cache; fi",
10 | "lint:fix": "eslint . --fix",
11 | "test": "jest && eslint ./src ./test",
12 | "dev": "jest --watch",
13 | "prepublish": "npm run build",
14 | "release": "./scripts/release.sh"
15 | },
16 | "jest": {
17 | "rootDir": "test",
18 | "testEnvironment": "node"
19 | },
20 | "repository": {
21 | "type": "git",
22 | "url": "git@github.com:algolia/algoliasearch-alexa-adapter.git"
23 | },
24 | "keywords": [
25 | "algolia",
26 | "search",
27 | "search api",
28 | "alexa skills kit",
29 | "voice ui",
30 | "amazon echo"
31 | ],
32 | "homepage": "https://github.com/algolia/algoliasearch-alexa-adapter",
33 | "bugs": "https://github.com/algolia/algoliasearch-alexa-adapter/issues",
34 | "author": {
35 | "name": "Algolia, Inc.",
36 | "url": "https://www.algolia.com"
37 | },
38 | "contributors": [
39 | {
40 | "name": "Algolia Team ",
41 | "url": "http://www.algolia.com"
42 | }
43 | ],
44 | "dependencies": {
45 | "alexa-sdk": "^1.0.14",
46 | "algoliasearch": "^3.20.2"
47 | },
48 | "devDependencies": {
49 | "alexa-sdk": "^1.0.6",
50 | "babel-cli": "^6.18.0",
51 | "babel-preset-latest": "^6.16.0",
52 | "babel-tape-runner": "^2.0.1",
53 | "conventional-changelog-cli": "^1.2.0",
54 | "doctoc": "^1.2.0",
55 | "eslint": "^3.12.0",
56 | "eslint-config-algolia": "^6.0.1",
57 | "jest": "^18.1.0",
58 | "just": "^0.1.8",
59 | "mversion": "^1.10.1",
60 | "semver": "^5.4.1"
61 | },
62 | "engines": {
63 | "node": ">=0.8"
64 | },
65 | "license": "MIT"
66 | }
67 |
--------------------------------------------------------------------------------
/quickstart.md:
--------------------------------------------------------------------------------
1 | # Quick start Guide
2 |
3 | To start, create an [Algolia account](https://algolia.com) and index with your data. To learn more about getting up and running, view the [Algolia documentation](https://algolia.com/doc) and the [quick start guide](https://www.algolia.com/doc/guides/getting-started/quick-start/).
4 |
5 | ## Setting up Lambda
6 |
7 | Next set up your Lambda function by following these steps:
8 | - Log in to your [AWS Management Console](http://aws.amazon.com/) and navigate to Lambda
9 | - Select your region in the upper-righthand corner of your dashboard
10 | - Currently this must be either US East (N. Virginia) or EU (Ireland)
11 | - Click on either **Get Started Now** or **Create a Lambda Function**
12 | - On the next screen, select the **Blank Function** blueprint
13 | - In the following screen, click on the dashed square and select **Alexa Skills Kit** from the dropdown, then click on **Next**
14 | - Name your function (this is only for you to be able to easily find when you return to the Lambda console) and confirm that the selected runtime is **Node.js 4.3**
15 | - Skip the example code for now
16 | - For handler, leave this as `index.handler`
17 | - For the role, follow these steps:
18 | - Select **Create new role from template(s)**
19 | - Enter a role name
20 | - Select **Simple Microservice permissions** from the policy templates list
21 | - Click **Next** and on the following screen select **Create function**
22 |
23 | ## Setting up our Alexa skill
24 |
25 | Now that we have our Lambda function set up, we need to set up our Alexa Skill through the following:
26 | - Log in to the [Amazon Developer Alexa Console](https://developer.amazon.com/edw/home.html) using your Amazon credentials
27 | - Beneath **Alexa Skills Kit** select **Get Started**
28 | - In the upper-righthand corner, you should see a yellow button that says **Add a New Skill** that you will click on
29 | - Fill out the fields on the next screen:
30 | - **Skill Type**: leave this as *Custom Interaction Model*
31 | - **Language**: Choose the language in which users will interact with your skill
32 | - **Name**: This is what's displayed in the Alexa app
33 | - **Invocation Name**: When users say `Alexa ask`, they follow it with the invocation name
34 | - It should be memorable and follow [Amazon's guidelines](https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/choosing-the-invocation-name-for-an-alexa-skill)
35 | - On the next screen, we'll fill out our intent schema and sample utterances
36 |
37 | ### Creating the intent schema
38 |
39 | An intent schema will outline the different intents and slots. The intent names should match the handlers we specify in our code (and vice-versa). A sample intent schema would look like this:
40 |
41 | ```json
42 | {
43 | "intents": [
44 | {
45 | "intent": "GetListingsIntent",
46 | "slots": [
47 | {
48 | "name": "query",
49 | "type": "AMAZON.US_CITY"
50 | },
51 | {
52 | "name": "available",
53 | "type": "AMAZON.DATE"
54 | }
55 | ]
56 | },
57 | {
58 | "intent": "FreshnessIntent",
59 | "slots": []
60 | },
61 | {
62 | "intent": "AMAZON.HelpIntent"
63 | },
64 | {
65 | "intent": "AMAZON.CancelIntent"
66 | },
67 | {
68 | "intent": "AMAZON.StopIntent"
69 | },
70 | {
71 | "intent": "AMAZON.StartOverIntent"
72 | },
73 | {
74 | "intent": "AMAZON.NoIntent"
75 | },
76 | {
77 | "intent": "AMAZON.YesIntent"
78 | }
79 | ]
80 | }
81 | ```
82 |
83 | An intent like our `FreshnessIntent`, which has a very simple interaction model (e.g. `When were the listings last updated?`), has no slots.
84 |
85 | Most intents, however, will have slots. A slot is like an argument for the intent. For example, our `GetListingsIntent` could be invoked by `What homes are available for sale in Houston?`, `What homes are available for sale in Tulsa?`, or `What new homes in Little Rock are available starting next week?`. New slot types are being added often and more information can be [found here](https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/built-in-intent-ref/slot-type-reference) and [here](https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/migrating-to-the-improved-built-in-and-custom-slot-types).
86 |
87 | **Very important to note**: currently for the Alexa adaptor the slot name for the full text search **must** be `query`. This is what will be passed to Algolia and will be searched for in the specified index. Other slots can be used for search parameters, as we'll see below.
88 |
89 | Fill out the custom slot types if necessary, and follow with the sample utterances.
90 |
91 | ### Sample utterances
92 |
93 | Sample utterances are the "training set" for Alexa to understand how people might interact with the skill. For our intent schema above, we might have the following sample utterances (truncated for length):
94 |
95 | ```
96 | GetListingsIntent what homes are available in {query}
97 | GetListingsIntent homes in {query}
98 | GetListingsIntent {query} homes available
99 | GetListingsIntent {query} homes for sale
100 | GetListingsIntent what homes are for sale in {query}
101 | GetListingsIntent what homes are available in {query} available starting {available}
102 | GetListingsIntent homes in {query} available starting {available}
103 | GetListingsIntent {query} homes available available starting {available}
104 | GetListingsIntent {query} homes for sale available starting {available}
105 |
106 | FreshnessIntent when were the listings last updated
107 | FreshnessIntent when the listings were last updated
108 | FreshnessIntent to tell me when listings were updated last
109 | FreshnessIntent the date of the last update
110 | ```
111 |
112 | Here we have the name of the intent on the left, the utterance on the right, and the slot(s) in curly braces. You can find more information on sample utterances in [the Amazon documentation](https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/defining-the-voice-interface#h2_sample_utterances).
113 |
114 | Save that, and on the next screen select the radio button for **AWS Lambda ARN (Amazon Resource Name)**, select the region you selected for your Lambda function, and paste in the ARN from your Lambda function. The ARN can be found in the upper-righthand corner on the Lambda function page.
115 |
116 | Save this, we'll be coming back to it later.
117 |
118 | ## Uploading and testing our code
119 |
120 | Write your code using the Algolia Alexa adapter, making sure you've installed the `algoliasearch-alexa` npm module and that you have your code in an index.js file, exporting the returned function result as the only export of that file. For example:
121 |
122 | ```javascript
123 | const algoliaAlexaAdapter = require('algoliasearch-alexa').default;
124 |
125 | const voiceSearch = algoliaAlexaAdapter({
126 | algoliaAppId: 'applicationId',
127 | algoliaApiKey: 'publicSearchKey',
128 | defaultIndexName: 'listings',
129 | alexaAppId: 'amzn1.echo-sdk-ams.app.[unique-value-here]',
130 | handlers: {
131 | onLaunch(launchRequest, session, response) {
132 | this.emit(':tell', 'Welcome to the skill!')
133 | },
134 | GetListingsIntent: {
135 | answerWith: function (data) {
136 | if(data.results.length) {
137 | this.emit(':tell', `There were ${data.results.hits.length} listings found.`);
138 | } else {
139 | this.emit(':tell', 'We could find no listings. Please try again.');
140 | }
141 | },
142 | params: {
143 | filters: function (requestBody) {
144 | return `available:${requestBody.request.intent.slots.available.value}`;
145 | }
146 | },
147 | },
148 | FreshnessIntent: function (intent, session, response) {
149 | const speechOutput = 'Listings on the listing store are updated every day. What city are you looking for?';
150 | this.emit(':ask', speechOutput);
151 | },
152 | },
153 | });
154 |
155 | module.exports = voiceSearch;
156 | ```
157 |
158 | Notice the synchronicity between our handler name and our intent schema. (`GetListingsIntent` in both.)
159 |
160 | Next, *without* zipping them into a directory themselves, zip up both the `index.js` file and `node_modules` directory. By default, when going through the GUI, whatever we want to zip is put into a directory, which causes an error that's not immediately obvious. We'll avoid that by going through the terminal:
161 |
162 | ```bash
163 | $ zip files.zip index.js -r node_modules/
164 | ```
165 |
166 | In the **Code** tab of the Lambda function manager, select **Upload a .ZIP file** for the code entry type and upload our new zip file. Click on the **Save and Test** button. A modal will appear that allow you to create a JSON payload that mimics what your Lambda function will receive from the Alexa Skills service.
167 |
168 | You can create this payload by going to the **Test** section of the skill setup section of your Amazon Developer Console and typing in an utterance. You do not need to preface it with `ask {invocation name}`. The JSON payload will appear below as the Lambda Request. Copy that and paste it in the modal and hit **Save and Test** at the bottom.
169 |
170 | All should go well and the execution result should have succeeded. You've got your Alexa Skill all set up and now just need to follow through in the Amazon Developer Console filling out the rest of the information and getting it published!
171 |
172 | ## Questions?
173 |
174 | Run into problems or have questions? [File an issue](https://github.com/algolia/algoliasearch-alexa/issues) or [create a PR](https://github.com/algolia/algoliasearch-alexa/pulls).
175 |
--------------------------------------------------------------------------------
/scripts/bump-package-version.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 |
3 | import fs from 'fs';
4 |
5 | import mversion from 'mversion';
6 | import semver from 'semver';
7 |
8 | const versionSrc = '../src/version.js';
9 | import currentVersion from '../src/version.js';
10 |
11 | if (!process.env.VERSION) {
12 | throw new Error('release: usage is VERSION=MAJOR.MINOR.PATCH npm run release');
13 | }
14 |
15 | const newVersion = process.env.VERSION;
16 |
17 | if (!semver.valid(newVersion)) {
18 | throw new Error(`release: provided new version (${newVersion}) is not a valid version per semver`);
19 | }
20 |
21 | if (semver.gte(currentVersion, newVersion)) {
22 | throw new Error(`release:
23 | provided new version is not higher than current version (${newVersion} <= ${currentVersion})`
24 | );
25 | }
26 |
27 | const colors = '\x1b[44m\x1b[37m%s\x1b[0m';
28 |
29 | console.log(colors, `Releasing ${newVersion}`);
30 | console.log(colors, `Updating ${versionSrc}`);
31 |
32 | const newContent = `export default '${newVersion}';`;
33 | fs.writeFileSync(`${__dirname}/${versionSrc}`, newContent);
34 |
35 | console.log(colors, 'Updating package.json');
36 |
37 | mversion.update(newVersion);
38 |
--------------------------------------------------------------------------------
/scripts/release.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e # exit when error
4 |
5 | if ! npm owner ls | grep -q "$(npm whoami)"
6 | then
7 | printf "Release: Not an owner of the npm repo, ask for it\n"
8 | exit 1
9 | fi
10 |
11 | currentBranch=`git rev-parse --abbrev-ref HEAD`
12 | if [ $currentBranch != 'master' ]; then
13 | printf "Release: You must be on master\n"
14 | exit 1
15 | fi
16 |
17 | if [[ -n $(git status --porcelain) ]]; then
18 | printf "Release: Working tree is not clean (git status)\n"
19 | exit 1
20 | fi
21 |
22 | if [[ $# -eq 0 ]] ; then
23 | printf "Release: use ``yarn release [major|minor|patch|x.x.x]``\n"
24 | exit 1
25 | fi
26 |
27 | VERSION=$1 babel-node ./scripts/bump-package-version.js
28 | conventional-changelog --infile CHANGELOG.md --same-file --preset angular
29 | doctoc README.md
30 | git commit -am "$(json -f package.json version)"
31 | git tag v`json -f package.json version`
32 | git push
33 | git push --tags
34 | npm publish
35 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import AlexaSDK from 'alexa-sdk';
2 | import searchConstructor from 'algoliasearch';
3 | import buildHandlers from './utils/build_handlers.js';
4 | import versionNumber from './version.js';
5 |
6 | export default function algoliaAlexaAdapter (opts) {
7 | if (!opts) {
8 | throw new Error('Must initialize with options');
9 | }
10 |
11 | const {
12 | algoliaAppId,
13 | algoliaApiKey,
14 | defaultIndexName,
15 | handlers,
16 | alexaAppId,
17 | languageStrings,
18 | dynamoDBTableName,
19 | algoliasearch = searchConstructor,
20 | Alexa = AlexaSDK,
21 | } = opts;
22 |
23 | const _StateString = Alexa.StateString;
24 |
25 | if (algoliaAppId === undefined) {
26 | throw new Error('Must initialize with algoliaAppId');
27 | }
28 |
29 | if (algoliaApiKey === undefined) {
30 | throw new Error('Must initialize with algoliaApiKey');
31 | }
32 |
33 | if (defaultIndexName === undefined) {
34 | throw new Error('Must initialize with defaultIndexName');
35 | }
36 |
37 | if (handlers === undefined) {
38 | throw new Error('Must initialize with handlers');
39 | }
40 |
41 | const skill = {};
42 | const client = algoliasearch(algoliaAppId, algoliaApiKey);
43 | client.addAlgoliaAgent(`Alexa Skills Kit Adapter ${versionNumber}`);
44 | const index = client.initIndex(defaultIndexName);
45 |
46 | skill.handler = function(event, context, callback) {
47 | const alexa = Alexa.handler(event, context, callback);
48 | alexa.appId = alexaAppId;
49 | alexa.dynamoDBTableName = dynamoDBTableName;
50 | if (languageStrings) {
51 | alexa.resources = languageStrings;
52 | }
53 | alexa.registerHandlers(...buildHandlers(handlers, index, _StateString));
54 | alexa.execute();
55 | };
56 |
57 | return skill;
58 | }
59 |
--------------------------------------------------------------------------------
/src/utils/build_handlers.js:
--------------------------------------------------------------------------------
1 | import {isFunction, isArray, isObject} from './is_of_type.js';
2 | import buildParams from './build_params.js';
3 |
4 | function hasAnswerWith(obj) {
5 | return obj && obj.answerWith !== undefined;
6 | }
7 |
8 | function buildFromObject(obj, index, stateString) {
9 | const result = Object.keys(obj).reduce((object, key) => {
10 | if (key === 'state') {
11 | return object;
12 | }
13 |
14 | if (isFunction(obj[key])) {
15 | object[key] = obj[key];
16 | return object;
17 | } else if (hasAnswerWith(obj[key])) {
18 | const func = obj[key].answerWith;
19 | const paramsObj = obj[key].params;
20 |
21 | object[key] = function() {
22 | const args = {event: this.event};
23 | const params = buildParams(paramsObj, this.event);
24 | let query = '';
25 | if (args.event.request.intent.slots && args.event.request.intent.slots.query) {
26 | query = args.event.request.intent.slots.query.value;
27 | }
28 |
29 | index
30 | .search(query, params)
31 | .then((results, err) => {
32 | Object.assign(args, {err, results});
33 | func.call(this, args);
34 | });
35 | };
36 |
37 | return object;
38 | } else {
39 | throw new Error('Intent handler must either be a function or an object ' +
40 | 'with key of "answerWith" which is a function.');
41 | }
42 | }, {});
43 |
44 | Object.defineProperty(result, stateString, {
45 | value: obj.state || '',
46 | });
47 |
48 | return [result];
49 | }
50 |
51 | function buildFromArray(arr, index, stateString) {
52 | return arr.map(obj => buildFromObject(obj, index, stateString)[0]);
53 | }
54 |
55 | export default function buildHandlers (handlers, index, stateString) {
56 | if (isArray(handlers)) {
57 | return buildFromArray(handlers, index, stateString);
58 | } else if (isObject(handlers)) {
59 | return buildFromObject(handlers, index, stateString);
60 | } else {
61 | throw new Error('Handlers must be either an array or an object.');
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/utils/build_params.js:
--------------------------------------------------------------------------------
1 | import {isObject, isFunction} from './is_of_type.js';
2 |
3 | export default function buildParams (params, event) {
4 | if (params === undefined || params === null) {
5 | return {};
6 | }
7 |
8 | if (!isObject(params)) {
9 | throw new Error('params must be an object');
10 | }
11 |
12 | // Copy the params object, to avoid overriding the original keys/value pairs
13 | const builtParams = Object.assign({}, params);
14 | for (const prop in builtParams) {
15 | if (builtParams.hasOwnProperty(prop)) {
16 | if (isFunction(builtParams[prop])) {
17 | builtParams[prop] = builtParams[prop](event);
18 | }
19 | }
20 | }
21 |
22 | return builtParams;
23 | }
24 |
--------------------------------------------------------------------------------
/src/utils/copy_except.js:
--------------------------------------------------------------------------------
1 | export default function(obj, key) {
2 | const dup = Object.assign({}, obj);
3 | delete dup[key];
4 | return dup;
5 | }
6 |
--------------------------------------------------------------------------------
/src/utils/is_of_type.js:
--------------------------------------------------------------------------------
1 | function isOfType(signature) {
2 | return function(param) {
3 | return Object.prototype.toString.call(param) === signature;
4 | };
5 | }
6 |
7 | const isObject = isOfType('[object Object]');
8 | const isFunction = isOfType('[object Function]');
9 | const isArray = Array.isArray;
10 |
11 | export {isOfType, isObject, isFunction, isArray};
12 |
--------------------------------------------------------------------------------
/src/version.js:
--------------------------------------------------------------------------------
1 | export default '1.1.3';
--------------------------------------------------------------------------------
/test/index.test.js:
--------------------------------------------------------------------------------
1 | /* global
2 | it, expect, describe, jest
3 | */
4 |
5 | import algoliaAlexaAdapter from '../src/index.js';
6 | import copyExcept from '../src/utils/copy_except.js';
7 |
8 | const algoliasearch = jest.fn(() => ({
9 | initIndex () {},
10 | addAlgoliaAgent () {},
11 | }));
12 |
13 | const Alexa = {
14 | handler () {
15 | return {
16 | registerHandlers () {},
17 | execute () {},
18 | };
19 | },
20 | };
21 |
22 | const args = {
23 | algoliaAppId: 'APP_ID',
24 | algoliaApiKey: 'API_KEY',
25 | alexaAppId: 'amzn1.echo-sdk-ams.app.fffff-aaa-fffff-0000',
26 | defaultIndexName: 'products',
27 | handlers: {
28 | HelpHandler: {
29 | answerWith () {},
30 | },
31 | },
32 | algoliasearch,
33 | Alexa,
34 | };
35 |
36 | const state = 'START_STATE';
37 | const argsWithState = {
38 | algoliaAppId: 'APP_ID',
39 | algoliaApiKey: 'API_KEY',
40 | alexaAppId: 'amzn1.echo-sdk-ams.app.fffff-aaa-fffff-0000',
41 | defaultIndexName: 'products',
42 | handlers: [{
43 | HelpHandler: {
44 | answerWith () {},
45 | },
46 | }, {
47 | state,
48 | HelpHandler: {
49 | answerWith () {},
50 | },
51 | 'AMAZON.YesIntent' () {},
52 | }],
53 | algoliasearch,
54 | Alexa,
55 | };
56 |
57 | const req = {
58 | session: {
59 | new: false,
60 | sessionId: 'amzn1.echo-api.session.[unique-value-here]',
61 | attributes: {},
62 | user: {
63 | userId: 'amzn1.ask.account.[unique-value-here]',
64 | },
65 | application: {
66 | applicationId: 'amzn1.ask.skill.[unique-value-here]',
67 | },
68 | },
69 | version: '1.0',
70 | request: {
71 | locale: 'en-US',
72 | timestamp: '2016-10-27T21:06:28Z',
73 | type: 'IntentRequest',
74 | requestId: 'amzn1.echo-api.request.[unique-value-here]',
75 | intent: {
76 | slots: {
77 | query: {
78 | name: 'query',
79 | value: 'snowball',
80 | },
81 | },
82 | name: 'AMAZON.YesIntent',
83 | },
84 | },
85 | context: {
86 | AudioPlayer: {
87 | playerActivity: 'IDLE',
88 | },
89 | System: {
90 | device: {
91 | supportedInterfaces: {
92 | AudioPlayer: {},
93 | },
94 | },
95 | application: {
96 | applicationId: 'amzn1.ask.skill.[unique-value-here]',
97 | },
98 | user: {
99 | userId: 'amzn1.ask.account.[unique-value-here]',
100 | },
101 | },
102 | },
103 | };
104 |
105 | describe('constructor', () => {
106 | describe('requires arguments', () => {
107 | it('cannot be empty', () => {
108 | expect(() => {
109 | algoliaAlexaAdapter();
110 | }).toThrow();
111 | });
112 |
113 | it('must be an object', () => {
114 | expect(() => {
115 | algoliaAlexaAdapter('APP_ID');
116 | }).toThrow();
117 | });
118 |
119 | describe('requires certain values', () => {
120 | it('must have algoliaAppId', () => {
121 | expect(() => {
122 | algoliaAlexaAdapter(copyExcept(args, 'algoliaAppId'));
123 | }).toThrow();
124 | });
125 |
126 | it('must have algoliaApiKey', () => {
127 | expect(() => {
128 | algoliaAlexaAdapter(copyExcept(args, 'algoliaApiKey'));
129 | }).toThrow();
130 | });
131 |
132 | it('must have defaultIndexName', () => {
133 | expect(() => {
134 | algoliaAlexaAdapter(copyExcept(args, 'defaultIndexName'));
135 | }).toThrow();
136 | });
137 |
138 | it('must have handlers', () => {
139 | expect(() => {
140 | algoliaAlexaAdapter(copyExcept(args, 'handlers'));
141 | }).toThrow();
142 | });
143 | });
144 | });
145 |
146 | it('returns an object', () => {
147 | expect(algoliaAlexaAdapter(args)).toEqual(expect.any(Object));
148 | });
149 |
150 | it('can handle state-based handlers', () => {
151 | expect(() => { algoliaAlexaAdapter(argsWithState); }).not.toThrow();
152 | });
153 | });
154 |
155 | describe('handler', () => {
156 | describe('without state', () => {
157 | it('executes without error', () => {
158 | expect(() => {
159 | algoliaAlexaAdapter(argsWithState).handler(req, {}, () => {});
160 | }).not.toThrow();
161 | });
162 | });
163 |
164 | describe('with state', () => {
165 | req.session.attributes[Alexa.StateString] = state;
166 | it('executes without error', () => {
167 | expect(() => {
168 | algoliaAlexaAdapter(argsWithState).handler(req, {}, () => {});
169 | }).not.toThrow();
170 | });
171 | });
172 | });
173 |
--------------------------------------------------------------------------------
/test/utils/build_handlers.test.js:
--------------------------------------------------------------------------------
1 | /* global
2 | it, expect, describe, jest, beforeEach
3 | */
4 |
5 | import buildHandlers from '../../src/utils/build_handlers.js';
6 | import {isArray, isObject} from '../../src/utils/is_of_type.js';
7 | import {StateString} from 'alexa-sdk';
8 |
9 | describe('handlers', () => {
10 | const searchSpy = jest.fn(() => Promise.resolve());
11 | const index = {
12 | search: searchSpy,
13 | };
14 |
15 | const handlers = {
16 | state: 'START_STATE',
17 | LaunchRequest () {},
18 | spyIntent: {
19 | answerWith () {},
20 | },
21 | withParamsIntent: {
22 | answerWith () {},
23 | params: {
24 | page: 10,
25 | },
26 | },
27 | withParamsWithFunctionIntent: {
28 | answerWith () {},
29 | params: {
30 | page (requestBody) {
31 | return requestBody.request.intent.slots.page.value;
32 | },
33 | },
34 | },
35 | unChangedIntent () {},
36 | };
37 | const builtHandlers = buildHandlers(handlers, index, StateString);
38 | const expectedQuery = 'query';
39 | const scope = {
40 | event: {
41 | request: {
42 | intent: {
43 | slots: {
44 | query: {
45 | value: expectedQuery,
46 | },
47 | page: {
48 | value: 5,
49 | },
50 | },
51 | },
52 | },
53 | },
54 | };
55 |
56 | beforeEach(() => {
57 | searchSpy.mockClear();
58 | });
59 |
60 | describe('when intent handler is specified', () => {
61 | describe('in a single object', () => {
62 | it('returns an array', () => {
63 | expect(isArray(builtHandlers)).toBe(true);
64 | });
65 | });
66 |
67 | describe('in an array of objects', () => {
68 | const multipleHandlers = buildHandlers([handlers, handlers], index, StateString);
69 |
70 | it('returns an array', () => {
71 | expect(isArray(multipleHandlers)).toBe(true);
72 | });
73 |
74 | it('returns an array of objects', () => {
75 | const allObjects = multipleHandlers.every(handler => isObject(handler));
76 | expect(allObjects).toBe(true);
77 | });
78 | });
79 |
80 | describe('is an object', () => {
81 | it('has a state', () => {
82 | expect(builtHandlers[0][StateString]).toEqual(handlers.state);
83 | });
84 |
85 | describe('with answerWith', () => {
86 | describe('without params to merge', () => {
87 | const expectedParams = {};
88 |
89 | describe('without a query slot', () => {
90 | const withoutQuery = JSON.parse(JSON.stringify(scope));
91 | delete withoutQuery.event.request.intent.slots;
92 |
93 | builtHandlers[0].spyIntent.call(withoutQuery);
94 |
95 | expect(searchSpy).toHaveBeenCalledWith('', expectedParams);
96 | });
97 |
98 | describe('when handler is invoked', () => {
99 | it('searches Algolia', () => {
100 | builtHandlers[0].spyIntent.call(scope);
101 |
102 | expect(searchSpy).toHaveBeenCalledWith(expectedQuery, expectedParams);
103 | });
104 | });
105 | });
106 |
107 | describe('with params to merge', () => {
108 | describe('when handler is invoked', () => {
109 | it('searches Algolia with params', () => {
110 | const expectedParams = {page: 10};
111 |
112 | builtHandlers[0].withParamsIntent.call(scope);
113 |
114 | expect(searchSpy).toHaveBeenCalledWith(expectedQuery, expectedParams);
115 | });
116 | });
117 |
118 | describe('with params with function', () => {
119 | const expectedParams = {page: 5};
120 |
121 | builtHandlers[0].withParamsWithFunctionIntent.call(scope);
122 |
123 | expect(searchSpy).toHaveBeenCalledWith(expectedQuery, expectedParams);
124 | });
125 | });
126 | });
127 |
128 | describe('without answerWith', () => {
129 | it('throws an error', () => {
130 | const newHandlers = {withoutAnswerWithIntent: {}};
131 | Object.assign(newHandlers, handlers);
132 |
133 | expect(() => {
134 | buildHandlers(newHandlers, index);
135 | }).toThrow();
136 | });
137 | });
138 | });
139 |
140 | describe('is a function', () => {
141 | it('does not search Algolia', () => {
142 | buildHandlers(handlers, index)[0].unChangedIntent();
143 | expect(searchSpy).not.toHaveBeenCalled();
144 | });
145 | });
146 | });
147 | });
148 |
--------------------------------------------------------------------------------
/test/utils/build_params.test.js:
--------------------------------------------------------------------------------
1 | /* global
2 | it, expect, describe
3 | */
4 |
5 | import buildParams from '../../src/utils/build_params.js';
6 |
7 | describe('buildParams', () => {
8 | describe('when nothing is specified', () => {
9 | it('returns an empty object if undefined', () => {
10 | expect(buildParams(undefined)).toEqual({});
11 | });
12 |
13 | it('returns an empty object if null', () => {
14 | expect(buildParams(null)).toEqual({});
15 | });
16 | });
17 |
18 | describe('when a non-object something is specified', () => {
19 | it('throws for a string', () => {
20 | expect(() => {
21 | buildParams('');
22 | }).toThrow();
23 | });
24 |
25 | it('throws for a boolean', () => {
26 | expect(() => {
27 | buildParams(true);
28 | }).toThrow();
29 | });
30 |
31 | it('throws for an array', () => {
32 | expect(() => {
33 | buildParams([]);
34 | }).toThrow();
35 | });
36 |
37 | it('throws for a number', () => {
38 | expect(() => {
39 | buildParams(1);
40 | }).toThrow();
41 | });
42 |
43 | it('throws for a set', () => {
44 | expect(() => {
45 | buildParams(new Set());
46 | }).toThrow();
47 | });
48 |
49 | it('throws for a map', () => {
50 | expect(() => {
51 | buildParams(new Map());
52 | }).toThrow();
53 | });
54 |
55 | it('throws for a function', () => {
56 | expect(() => {
57 | buildParams(() => {});
58 | }).toThrow();
59 | });
60 | });
61 |
62 | describe('when an object is specified', () => {
63 | describe('when values are all strings', () => {
64 | it('returns the object as is', () => {
65 | const params = {
66 | page: 10,
67 | hitsPerPage: 12,
68 | };
69 | expect(buildParams(params)).toEqual(params);
70 | });
71 | });
72 |
73 | describe('when a value is a function', () => {
74 | it('returns as a value the return value of that function', () => {
75 | const params = {
76 | page: 10,
77 | hitsPerPage (requestBody) {
78 | return requestBody.request.intent.slots.num.value;
79 | },
80 | };
81 | const body = {request: {intent: {slots: {num: {value: 12}}}}};
82 | const expected = {
83 | page: 10,
84 | hitsPerPage: 12,
85 | };
86 |
87 | expect(buildParams(params, body)).toEqual(expected);
88 | });
89 | });
90 | });
91 | });
92 |
--------------------------------------------------------------------------------
/test/utils/is_of_type.test.js:
--------------------------------------------------------------------------------
1 | /* global
2 | it, expect, describe
3 | */
4 |
5 | import {isOfType, isObject, isFunction, isArray} from '../../src/utils/is_of_type.js';
6 |
7 | describe('isOfType', () => {
8 | it('returns a function', () => {
9 | expect(isOfType('[object Object]')).toBeInstanceOf(Function);
10 | });
11 | });
12 |
13 | describe('isObject', () => {
14 | it('return true if an object', () => {
15 | expect(isObject({})).toEqual(true);
16 | });
17 |
18 | it('return false if not an object', () => {
19 | expect(isObject([])).toEqual(false);
20 | });
21 | });
22 |
23 | describe('isFunction', () => {
24 | it('return true if a function', () => {
25 | expect(isFunction(() => {})).toEqual(true);
26 | });
27 |
28 | it('return false if not a function', () => {
29 | expect(isFunction({})).toEqual(false);
30 | });
31 | });
32 |
33 | describe('isArray', () => {
34 | it('return true if a array', () => {
35 | expect(isArray([])).toEqual(true);
36 | });
37 |
38 | it('return false if not a array', () => {
39 | expect(isArray({})).toEqual(false);
40 | });
41 | });
42 |
--------------------------------------------------------------------------------