ForgeVision Engineer Blog

フォージビジョン エンジニア ブログ

TypeScriptでAlexaスキル開発(準備篇)

こんにちは、ソリューション技術部の菊池です。

Echo Spot発売されましたね。いいですね。独自のスキルを動かしたいですね。

というわけでAlexaスキルを作成していきます。まずはTypeScriptでAlexaスキルを開発する準備をしていきます。 次回以降でEcho Spotの画面を利用したスキル作成をしていければと思っています。

f:id:fvkikuchi:20180806192136j:plain

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.jsontslint.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-coreask-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.

動作確認

f:id:fvkikuchi:20180806161004p:plain

最後に

今回はAlexaスキルをTypeScriptで開発する環境を整えました。

次回以降はEcho Spotに対応したスキルの開発について次のようなトピックで書いていきます。

  • 画像の表示
  • タッチアクション