こんにちは、ソリューション技術部の菊池です。
Echo Spot発売されましたね。いいですね。独自のスキルを動かしたいですね。
というわけでAlexaスキルを作成していきます。まずはTypeScriptでAlexaスキルを開発する準備をしていきます。 次回以降でEcho Spotの画面を利用したスキル作成をしていければと思っています。
TL;DR
ask-cli
でのひな形をTypeScriptに対応させる- ASK SDK v2はTypeScriptの型定義が含まれているので便利
執筆環境
コーディングにはVisual Studio Codeを使っています。 Lambda関数とAlexaスキルのアップロードはローカルマシンのターミナルからASK CLIを利用して行います。
執筆時の主なライブラリ等のバージョンは以下になります。
- ask-cli 1.4.1
- node 10.8.0
- typescript 3.0.1
- yarn 1.9.2
- webpack 4.16.4
Alexaスキル開発
ask-cli
でひな形を作成します。スキル名はここではhelloworld
としています。
$ ask new -n helloworld New project for Alexa skill created.
以下のようなフォルダ構成が作成されます。
helloworld ├── lambda │ └── custom │ ├── index.js │ └── package.json ├── models │ └── en-US.json └── skill.json
これをTypeScriptで開発するために次のように変えていきます。
helloworld ├── jest.config.js ├── lambda │ └── custom ├── models │ └── en-US.json ├── package.json ├── skill.json ├── src │ ├── __tests__ │ │ └── index.ts │ └── index.ts ├── tools │ └── gen-package-json.ts ├── tsconfig.json ├── tslint.json └── webpack.config.ts
TypeScript環境の構築
lambda/custom
以下のファイルが最終的にAWS Lambdaにデプロイされますが、ここは生成物が置かれるファルダとしてだけ利用します。よって中のファイルで必要なものは移動し、あとは削除します。ついでにyarn install
を実行して依存パッケージをダウンロードしておきます。
$ cd helloworld $ mkdir src $ mv lambda/custom/index.js src/index.ts $ mv lambda/custom/package.json . $ rm -rf lambda/custom/* $ yarn install yarn install v1.9.2 [SNIP] ✨ Done in 0.61s.
TypeScript関連パッケージをインストールします。
$ yarn add -D typescript tslint @types/node yarn add v1.9.2 [SNIP] ✨ Done in 2.54s.
TypeScriptの設定ファイル(tsconfig.json
、tslint.json
)を作成しましょう。設定ファイルはお好みで。参考に私の設定ファイルを貼っておきます。
tsconfig.json
ask-cli
で作成されるLambda関数のランタイムはNode.js 8.10になるので"target": "es2017"
としています。
{ "compilerOptions": { "target": "es2017", "lib": [ "es2017" ], "module": "commonjs", "moduleResolution": "node", "strict": true, "esModuleInterop": true, "removeComments": true, "preserveConstEnums": true, "sourceMap": true }, "exclude": [ "node_modules" ] }
tslint.json
tslint-config-airbnb
を継承しているので、利用する場合はyarn add -D tslint-config-airbnb
してください。
{ "extends": "tslint-config-airbnb", "exclude": [ "node_modules" ] }
webpackの設定
ビルドにはwebpack
を利用します。
まず必要なパッケージをインストールします。
$ yarn add -D webpack webpack-cli webpack-node-externals ts-loader ts-node @types/webpack @types/webpack-node-externals yarn add v1.9.2 [SNIP] ✨ Done in 10.22s.
設定ファイルを作成します。
webpack.config.ts
import * as path from 'path'; import * as webpack from 'webpack'; import webpackNodeExternals = require('webpack-node-externals'); const config: webpack.Configuration = { entry: './src/index.ts', mode: 'development', target: 'node', externals: [webpackNodeExternals()], devtool: 'inline-source-map', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, ], }, resolve: { extensions: ['.tsx', '.ts', '.js'], }, output: { filename: 'index.js', path: path.resolve(__dirname, 'lambda/custom'), library: 'index', libraryTarget: 'commonjs2', }, }; export default config;
動作確認をします。
$ yarn run webpack --config webpack.config.ts yarn run v1.9.2 Hash: ae95b4b33e8d6c63c20c Version: webpack 4.16.4 Time: 544ms Built at: 2018/08/03 18:28:34 Asset Size Chunks Chunk Names index.js 19.1 KiB main [emitted] main Entrypoint main = index.js [./src/index.ts] 3.03 KiB {main} [built] [12 errors] [ask-sdk-core] external "ask-sdk-core" 42 bytes {main} [built] [SNIP]
見事にエラーの山です。TypeScriptの構文チェックを厳しめにしているのに、JavaScriptファイルをリネームしただけでは当たり前の話です。
それではソースを書き換えて行きましょう。
TypeScriptへの書き換え
スキル開発はask-sdk-core
とask-sdk-model
の2つのパッケージを利用します。Alexaとやり取りするJSONデータのモデルがask-sdk-model
パッケージに含まれ、その他のロジックがask-sdk-core
に含まれます。
喜ばしいことに両パッケージともTypeScriptの定義ファイル(.d.ts
)が含まれるため、いつものyarn add -D @types/...
は必要ありません。
import { HandlerInput, RequestHandler, ErrorHandler, SkillBuilders } from 'ask-sdk-core'; import { SessionEndedRequest } from 'ask-sdk-model'; const launchRequestHandler: RequestHandler = { canHandle(handlerInput: HandlerInput) { return handlerInput.requestEnvelope.request.type === 'LaunchRequest'; }, handle(handlerInput: HandlerInput) { const speechText = 'Welcome to the Alexa Skills Kit, you can say hello!'; return handlerInput.responseBuilder .speak(speechText) .reprompt(speechText) .withSimpleCard('Hello World', speechText) .getResponse(); }, }; const helloWorldIntentHandler: RequestHandler = { canHandle(handlerInput: HandlerInput) { return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'HelloWorldIntent'; }, handle(handlerInput: HandlerInput) { const speechText = 'Hello World!'; return handlerInput.responseBuilder .speak(speechText) .withSimpleCard('Hello World', speechText) .getResponse(); }, }; const helpIntentHandler: RequestHandler = { canHandle(handlerInput: HandlerInput) { return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent'; }, handle(handlerInput: HandlerInput) { const speechText = 'You can say hello to me!'; return handlerInput.responseBuilder .speak(speechText) .reprompt(speechText) .withSimpleCard('Hello World', speechText) .getResponse(); }, }; const cancelAndStopIntentHandler: RequestHandler = { canHandle(handlerInput: HandlerInput) { return handlerInput.requestEnvelope.request.type === 'IntentRequest' && (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent' || handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent'); }, handle(handlerInput: HandlerInput) { const speechText = 'Goodbye!'; return handlerInput.responseBuilder .speak(speechText) .withSimpleCard('Hello World', speechText) .getResponse(); }, }; const sessionEndedRequestHandler: RequestHandler = { canHandle(handlerInput: HandlerInput) { return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest'; }, handle(handlerInput: HandlerInput) { const { reason } = handlerInput.requestEnvelope.request as SessionEndedRequest; console.log(`Session ended with reason: ${reason}`); return handlerInput.responseBuilder.getResponse(); }, }; const errorHandler: ErrorHandler = { canHandle() { return true; }, handle(handlerInput: HandlerInput, error: Error) { console.log(`Error handled: ${error.message}`); return handlerInput.responseBuilder .speak('Sorry, I can\'t understand the command. Please say again.') .reprompt('Sorry, I can\'t understand the command. Please say again.') .getResponse(); }, }; const skillBuilder = SkillBuilders.custom(); exports.handler = skillBuilder .addRequestHandlers( launchRequestHandler, helloWorldIntentHandler, helpIntentHandler, cancelAndStopIntentHandler, sessionEndedRequestHandler, ) .addErrorHandlers(errorHandler) .lambda();
コンパイルの確認をします。今度はエラーが出ずに成功してくれるはずです。
$ yarn run webpack --config webpack.config.ts yarn run v1.9.2 Hash: 2c555518c4e62fb1603a Version: webpack 4.16.4 Time: 1022ms Built at: 2018/08/03 19:01:18 Asset Size Chunks Chunk Names index.js 19.6 KiB main [emitted] main Entrypoint main = index.js [./src/index.ts] 3.14 KiB {main} [built] [ask-sdk-core] external "ask-sdk-core" 42 bytes {main} [built]
テスト
テストは忘れずに書いておきましょう。
VirtualAlexaを使うことでユニットテストを簡単に書くことができます。
$ yarn add -D jest ts-jest virtual-alexa @types/jest yarn add v1.9.2 [SNIP] ✨ Done in 17.31s.
jest.config.js
module.exports = { verbose: true, transform: { '^.+\\.tsx?$': 'ts-jest' }, testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', testEnvironment: 'node', roots: ['<rootDir>/src/'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] };
src/__test__/index.ts
import { VirtualAlexa, SkillResponse } from 'virtual-alexa'; import { handler } from '../index'; describe('helloworld skill', () => { let alexa: VirtualAlexa; beforeEach(() => { alexa = VirtualAlexa.Builder() .handler(handler) .interactionModelFile('./models/en-US.json') .create(); }); it('LanuchRequest', async () => { const speechText = 'Welcome to the Alexa Skills Kit, you can say hello!'; const payload = await alexa.launch(); expect(payload.response.outputSpeech.ssml).toContain(speechText); expect(payload.response.shouldEndSession).toBeFalsy(); }); it('HelloWorldIntent', async () => { const speechText = 'Hello World!'; let payload = await alexa.utter('hello') as SkillResponse; expect(payload.response.outputSpeech.ssml).toContain(speechText); payload = await (alexa.utter('say hello')) as SkillResponse; expect(payload.response.outputSpeech.ssml).toContain(speechText); payload = await (alexa.utter('say hello world')) as SkillResponse; expect(payload.response.outputSpeech.ssml).toContain(speechText); }); it('HelpIntent', async () => { const speechText = 'You can say hello to me!'; const payload = await alexa.intend('AMAZON.HelpIntent') as SkillResponse; expect(payload.response.outputSpeech.ssml).toContain(speechText); expect(payload.response.shouldEndSession).toBeFalsy(); }); it('CancelAndStopIntent', async () => { const speechText = 'Goodbye!'; let payload = await alexa.intend('AMAZON.CancelIntent') as SkillResponse; expect(payload.response.outputSpeech.ssml).toContain(speechText); payload = await alexa.intend('AMAZON.StopIntent') as SkillResponse; expect(payload.response.outputSpeech.ssml).toContain(speechText); }); });
テストを実行してみます。
$ yarn run jest yarn run v1.9.2 PASS src/__tests__/index.ts hello world skill ✓ LanuchRequest (10ms) ✓ HelloWorldIntent (3ms) ✓ HelpIntent (1ms) ✓ CancelAndStopIntent (1ms) Test Suites: 1 passed, 1 total Tests: 4 passed, 4 total Snapshots: 0 total Time: 1.233s, estimated 2s Ran all test suites.
デプロイ
lambda/custom
以下のファイルがask-cli
によってデプロイされます。このフォルダにpackage.json
を置いておくとデプロイの際に依存パッケージを自動的にダウンロードしてくれます。
しかしプロジェクトルートのpackage.json
をそのままコピーするとdevDependencies
もインストールされてしまうため、Lambdaのコードが肥大化します。なので必要な項目だけをコピーすることにします。
$ yarn add -D pjson yarn add v1.9.2 [SNIP] ✨ Done in 2.78s.
tools/gen-package-json.ts
import { name, description, version, main, license, dependencies, } from 'pjson'; const packageJson = { name, description, version, main, license, dependencies, }; console.log(JSON.stringify(packageJson));
いくつかNPM scriptsを定義しておきます。
- package.json(抜粋)
{ "scripts": { "build": "webpack --config webpack.config.ts", "pjson": "ts-node ./tools/gen-package-json.ts > ./lambda/custom/package.json", "test": "jest", "lint": "tslint src/**/*.ts", "clean": "rm -rf ./lambda/custom/*" }, }
ではデプロイしてみましょう。
$ yarn clean yarn run v1.9.2 $ rm -rf ./lambda/custom/* ✨ Done in 0.09s. $ yarn pjson yarn run v1.9.2 $ ts-node ./tools/gen-package-json.ts > ./lambda/custom/package.json ✨ Done in 0.42s. $ yarn build yarn run v1.9.2 $ webpack --config webpack.config.ts Hash: e7686c9c8a86a4658e6a Version: webpack 4.16.4 Time: 1121ms Built at: 2018/08/06 14:51:35 Asset Size Chunks Chunk Names index.js 19.7 KiB main [emitted] main Entrypoint main = index.js [./src/index.ts] 3.16 KiB {main} [built] [ask-sdk-core] external "ask-sdk-core" 42 bytes {main} [built] ✨ Done in 2.51s. $ ask deploy -------------------- Create Skill Project -------------------- Profile for the deployment: [default] Skill Id: amzn1.ask.skill.xxxxxxxx Skill deployment finished. Model deployment finished. Lambda deployment finished. Lambda function(s) created: [Lambda ARN] arn:aws:lambda:us-east-1:xxxxxxxx:function:ask-custom-helloworld-default Your skill is now deployed and enabled in the development stage. Try invoking the skill by saying “Alexa, open {your_skill_invocation_name}” or simulate an invocation via the `ask simulate` command.
動作確認
最後に
今回はAlexaスキルをTypeScriptで開発する環境を整えました。
次回以降はEcho Spotに対応したスキルの開発について次のようなトピックで書いていきます。
- 画像の表示
- タッチアクション