Help us understand the problem. What is going on with this article?

TypeScript を使って Alexa Custom Skills を作ろう まとめ

はじめに

今年の1月頃から始めたTypeScript を使って Alexa Custom Skills を作ろうの投稿のまとめです。
以前の記事と重複する点が多々あるかと思いますが、始めた頃はSDKがV1だったこともあり見直しをしました。

前提条件

  • Node.js / npm がインストールされていること
  • ASK CLIをインストール、セットアップ済みであること

設計

アーキテクチャパターンについて考える

せっかく TypeScript を使うのに、ただTypeScriptで書くだけではつまらないので、
WEBアプリケーションフレームワークのMVCのようなアーキテクチャパターンをカスタムスキル作成でも考えてみます。

MVCをざっくり解説

MVCでは、プログラム構造を大きく以下の3つに分割しています(ざっくり)。

名前 概要
Model(M) ビジネスロジックを書く
View(V) ユーザに見える部分を書く
ユーザからの要求を取得する部分を書く
Controller(C) ユーザからの要求を受け付けて、インテントを経由して必要であればモデルに渡して結果(ビュー)を返す

Alexa Custom Skillsの場合を考える

MVCと似た感じで、Alexa Custom Skillsについては、以下の3つに分類していきます。

名前 概要
Model(M) ビジネスロジックとかを書く
Utterance(U) ユーザとAlexaの対話内容(発話)部分を書く
Handler(H) 対話の状態によって、ユーザからの要求(発話)を受け付けて、必要であればモデルに渡して結果(発話)を返す

以上の感じで、ざっくりディレクトリ構成を決めていきます。

ディレクトリ構成を決める

プロジェクトルートディレクトリ構成

※ ここのmodelsは前述のモデルとは関係ありません

ProjectRootDirectory
├── .ask              … Alexa Skills Kit ディレクトリ
│   └── config        … スキル構成情報
├── lambda            … lambda関数格納 ディレクトリ
│   └── custom        … カスタムスキル関数格納 ディレクトリ
├── models            … モデル(インテントスキーマ等) ディレクトリ
│   └── ja-JP.json    … ローカライズファイル(日本語)
└── skill.json        … スキルマニフェストファイル

AWS Lambda ファンクション ディレクトリ構成

前述のディレクトリ構成のlambda部分です。
シンプルに!

/lambda/custom
lambda
└── custom
    ├── src           … ソース(TypeScript)
    | ├── handlers    … ハンドラ(インテントを受け取り
    | ├── models      … モデル(ビジネスロジック)
    | └── utterances  … 発話内容を組み立てるとこ
    └── test          … テスト

サンプルソースの作成

ASK CLIを利用し、新しいスキルのプロジェクトを作成します。

$ ask new
? Please type in your new skill name:  alexa-skill-sample-v2
New project for Alexa skill created.
-alexa-skill-sample-v2
  -.ask
    -config
  -lambda
    -custom
      -index.js
  -models
    -en-US.json
  -skill.json

以降の作業は、lambda/customがカレントディレクトリとします。

TypeScript 開発準備

TypeScriptで開発するために必要なモジュールをインストールします。
リンターは任意でご利用ください。

# インストール
npm install --save-dev typescript @types/node

続けてtsconfig.jsonを作成します。

# オプションファイル作成
$(npm bin)/tsc --init

オプションは任意です。
例) tsconfig.json

TypeScript への変換

TypeScriptファイル格納用のディレクトリを作成し、既存のindex.jsファイルを移動&名前変更します。

# ディレクトリ作成
mkdir -p src

# 移動&名前変更
mv ./index.js ./src/index.ts

型を定義

移動したファイルへ型を定義していきます。
import部分とハンドラの一部の定義例です。
以下のような形で書き換えていきます。

./src/index.ts
-const Alexa = require('ask-sdk-core');
+import * as Alexa from 'ask-sdk-core';
./src/index.ts
-const LaunchRequestHandler = {
-  canHandle(handlerInput) {
+const LaunchRequestHandler: Alexa.RequestHandler = {
+  canHandle(handlerInput: Alexa.HandlerInput) {
     return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
   },
-  handle(handlerInput) {
+  handle(handlerInput: Alexa.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();
   },
 };

例) 型定義

実装

前述の設計に合わせたディレクトリ&ファイルを作成します。

# ディレクトリ作成
mkdir -p ./src/handlers ./src/utterances ./src/models test

ハンドラの分離

index.tsからハンドラを分離していきます。
分離するためのファイルを準備します。

# 初期ファイルの作成
touch \
./src/handlers/launchRequestHandler.ts \
./src/handlers/helloWorldIntentHandler.ts \
./src/handlers/helpIntentHandler.ts \
./src/handlers/cancelAndStopIntentHandler.ts \
./src/handlers/sessionEndedRequestHandler.ts \
./src/handlers/errorHandler.ts

例としてLaunchRequestHandlerの分離例です。
index.tsからlaunchRequestHandler.tsへ切り貼りします。
外部からインポートできるようにexportを指定しモジュール化します。

./src/handlers/launchRequestHandler.ts
+ import * as Alexa from 'ask-sdk-core';
+
- const LaunchRequestHandler: Alexa.RequestHandler = {
+ export const LaunchRequestHandler: Alexa.RequestHandler = {
  canHandle(handlerInput: Alexa.HandlerInput) {
    return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
  },
  handle(handlerInput: Alexa.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();
  },
};

index.tsでは、分離したハンドラモジュールをインポートします。

./src/index.ts
+ import { LaunchRequestHandler } from './handlers/launchRequestHandler';
+ import { HelloWorldIntentHandler } from './handlers/helloWorldIntentHandler';
+ import { HelpIntentHandler } from './handlers/helpIntentHandler';
+ import { CancelAndStopIntentHandler } from './handlers/cancelAndStopIntentHandler';
+ import { SessionEndedRequestHandler } from './handlers/sessionEndedRequestHandler';
+ import { ErrorHandler } from './handlers/errorHandler';

例) ハンドラ分離

発話内容の分離

ハンドラではどのインテントで呼び出されるか、どのように発話する内容を返すかを管理するようにし、
発話内容自体も分離していきます。
分離するためのファイル、発話内容結果を返すクラスを定義します。

# 初期ファイルの作成
touch \
./src/utterances/helpUtterance.ts \
./src/utterances/cancelAndStopUtterance.ts \
./src/utterances/errorUtterance.ts \
./src/utterances/helloWorldUtterance.ts \
./src/utterances/launchRequestUtterance.ts \
./src/utterances/utteranceBase.ts
# 発話内容結果
mkdir -p ./src/entities
touch ./src/entities/utteranceResultBase.ts

発話内容結果クラスは以下のようになっています。

./src/entities/utteranceResultBase.ts
export interface IUtteranceResultBase {
  speech: string;
  repromptSpeech?: string;
  cardTitle?: string;
  cardContent?: string;
}

例としてHelloWorldIntentHandlerの分離例です。
helloWorldIntentHandler.tsからhelloWorldUtterance.tsへ初内容部分を分離します。
外部からインポートできるようにexportを指定しモジュール化します。

ここから

./src/handlers/helloWorldIntentHandler.ts
   handle(handlerInput: Alexa.HandlerInput) {
     const speechText = 'Hello World!';

     return handlerInput.responseBuilder
       .speak(speechText)
       .withSimpleCard('Hello World', speechText)
       .getResponse();
   },

Hello World!カード情報部分を分離します。

./src/utterances/helloWorldUtterance.ts
import { UtteranceBase } from './utteranceBase';
import { IUtteranceResultBase } from '../entities/utteranceResultBase';

export class HelloWorldUtterance extends UtteranceBase {
  constructor() {
    super();
  }

  public respond(): IUtteranceResultBase {
    const speechText = 'Hello World!';

    return {
      speech: speechText,
      cardTitle: 'Hello World',
      cardContent: speechText
    };
  }
}

helloWorldIntentHandler.tsへ分離した発話クラスをインポートして変更します。

./src/handlers/helloWorldIntentHandler.ts
 import * as Alexa from 'ask-sdk-core';
+import { createUtterance } from '../factories/utteranceFactory';
+import { HelloWorldUtterance as Utterance } from '../utterances/helloWorldUtterance';

 export const HelloWorldIntentHandler: Alexa.RequestHandler = {
   canHandle(handlerInput: Alexa.HandlerInput) {
     return handlerInput.requestEnvelope.request.type === 'IntentRequest'
       && handlerInput.requestEnvelope.request.intent.name === 'HelloWorldIntent';
   },
   handle(handlerInput: Alexa.HandlerInput) {
-    const speechText = 'Hello World!';
+    const uttranceResult = createUtterance(Utterance).respond();

     return handlerInput.responseBuilder
-      .speak(speechText)
-      .withSimpleCard('Hello World', speechText)
+      .speak(uttranceResult.speech)
+      .withSimpleCard(
+        (uttranceResult.cardTitle as string),
+        (uttranceResult.cardContent as string)
+      )
       .getResponse();
   },
 };

createUtterance()はインスタンス作成するために作成した関数です。
以下の書き方でも問題ありません。

const utterance = new Utterance();
const uttranceResult = utterance.respond();

テスト&カバレッジ

テストには以下のモジュールを利用します。

  • mocha
    • Javascript テストフレームワーク
  • chai
    • アサーションライブラリ
  • ts-node
    • typescriptをnode上で実行してくれるライブラリ
  • nyc
    • istanbulというカバレッジ計測ライブラリのコマンドラインツール
# インストール
npm i --save-dev mocha chai nyc ts-node @types/mocha @types/chai

helloWorldUtterance.tsのテストファイルを作成します。

./test/helloWorldUtterance.test.ts
import * as Mocha from 'mocha';
import { assert } from 'chai';
import { HelloWorldUtterance as Utterance } from '../src/utterances/helloWorldUtterance';

describe('HelloWorldUtterance Class', () => {
  let target: Utterance;

  before(() => {
    target = new Utterance();
  });

  describe('respond()', () => {
    it('発話テキストが一致すること', () => {
      const result = target.respond();

      assert.equal(result.speech, 'Hello World!');
    })
  });
});

テストを実行してみましょう。
以下のように表示されれば成功です。

$(npm bin)/mocha --require ts-node/register ./test/helloWorldUtterance.test.ts

HelloWorldUtterance Class
    respond()
      ✓ 発話テキストが一致すること


  1 passing (5ms)

カバレッジも表示できるようにpackage.jsonに以下のようにnyc部分を追記します。

./package.json
{
  "devDependencies": {
    ...
  },
  "nyc": {
    "include": [
      "src/*.ts",
      "src/**/*.ts"
    ],
    "exclude": [],
    "extension": [
      ".ts"
    ],
    "require": [
      "ts-node/register"
    ],
    "reporter": [
      "json",
      "html",
      "text",
      "text-summary",
      "json-summary"
    ],
    "sourceMap": true,
    "all": true
  }
}

カバレッジも確認しましょう。
テスト結果表示後に以下のように表示されれば成功です。

$(npm bin)/nyc $(npm bin)/mocha --require ts-node/register ./test/helloWorldUtterance.test.ts

--------------------------------|----------|----------|----------|----------|-------------------|
File                            |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
--------------------------------|----------|----------|----------|----------|-------------------|
All files                       |     6.25 |        0 |       12 |     6.52 |                   |
 src                            |        0 |        0 |        0 |        0 |                   |
  index.ts                      |        0 |        0 |        0 |        0 |... 14,15,16,17,18 |
 src/entities                   |        0 |      100 |      100 |        0 |                   |
  utteranceResultBase.ts        |        0 |      100 |      100 |        0 |                 2 |
 src/factories                  |        0 |      100 |        0 |        0 |                   |
  utteranceFactory.ts           |        0 |      100 |        0 |        0 |             2,4,6 |
 src/handlers                   |        0 |        0 |        0 |        0 |                   |
  cancelAndStopIntentHandler.ts |        0 |        0 |        0 |        0 |   2,3,4,5,7,12,13 |
  errorHandler.ts               |        0 |      100 |        0 |        0 |2,3,4,5,7,10,11,12 |
  helloWorldIntentHandler.ts    |        0 |        0 |        0 |        0 |   2,3,4,5,7,11,12 |
  helpIntentHandler.ts          |        0 |        0 |        0 |        0 |   2,3,4,5,7,11,12 |
  launchRequestHandler.ts       |        0 |      100 |        0 |        0 |   2,3,4,5,7,10,11 |
  sessionEndedRequestHandler.ts |        0 |        0 |        0 |        0 |      2,3,5,8,9,11 |
 src/utterances                 |       20 |      100 |    27.27 |       20 |                   |
  cancelAndStopUtterance.ts     |        0 |      100 |        0 |        0 |     2,3,6,9,10,17 |
  errorUtterance.ts             |        0 |      100 |        0 |        0 |     2,3,6,9,10,16 |
  helloWorldUtterance.ts        |      100 |      100 |      100 |      100 |                   |
  helpUtterance.ts              |        0 |      100 |        0 |        0 |     2,3,6,9,10,18 |
  launchRequestUtterance.ts     |        0 |      100 |        0 |        0 |     2,3,6,9,10,18 |
  utteranceBase.ts              |      100 |      100 |      100 |      100 |                   |
--------------------------------|----------|----------|----------|----------|-------------------|

=============================== Coverage summary ===============================
Statements   : 6.25% ( 6/96 )
Branches     : 0% ( 0/20 )
Functions    : 12% ( 3/25 )
Lines        : 6.52% ( 6/92 )
================================================================================

機能の拡張

HelloWorldIntentに機能を拡張したいと思います。
Hello World!ではなく、呼び出された時間によって発話内容(挨拶)を変えたいです。

挨拶の種類と該当時間

ざっくりと挨拶の種類と該当時間は、

  • Good morning!(6時〜12時の間)
  • Good afternoon!(12時〜17時の間)
  • Good evening!(上記以外)

の3種類とします。

まずは挨拶の種類指定された時間で挨拶種類を返す関数を作成します。

# ファイルの作成
touch ./src/models/getGreetingType.ts
./src/models/getGreetingType.ts
export enum GreetingTypes {
  Morning,
  Afternoon,
  Evening
}

export function getGreetingType(hours: number) {
  let retValue: GreetingTypes;

  if (hours >= 6 && hours < 12) {
    retValue = GreetingTypes.Morning;
  } else if (hours >= 12 && hours < 17) {
    retValue = GreetingTypes.Afternoon;
  } else {
    retValue = GreetingTypes.Evening;
  }

  return retValue;
}

次に発話クラスを修正します。
渡された時間によって先程作成した処理で挨拶種類を取得し発話内容を決定します。

./src/utterances/helloWorldUtterance.ts
 import { UtteranceBase } from './utteranceBase';
 import { IUtteranceResultBase } from '../entities/utteranceResultBase';
+import { GreetingTypes, getGreetingType } from '../models/getGreetingType';

 export class HelloWorldUtterance extends UtteranceBase {
   constructor() {
     super();
   }

-  public respond(): IUtteranceResultBase {
-    const speechText = 'Hello World!';
+  public respond(hours: number): IUtteranceResultBase {
+    const type = getGreetingType(hours);
+
+    let speechText: string;
+
+    switch (type) {
+      case GreetingTypes.Morning:
+        speechText = 'Good morning!';
+        break;
+      case GreetingTypes.Afternoon:
+        speechText = 'Good afternoon!';
+        break;
+      case GreetingTypes.Evening:
+        speechText = 'Good evening!';
+        break;
+      default:
+        speechText = 'Hello World!';
+        break;
+    }

     return {
       speech: speechText,
       cardTitle: 'Hello World',
       cardContent: speechText
     };
   }
 }

最後にハンドラです。
ハンドラで現在の時間を取得し、発話クラスへ渡します。

./src/handlers/helloWorldIntentHandler.ts
   handle(handlerInput: Alexa.HandlerInput) {
-    const uttranceResult = createUtterance(Utterance).respond();
+    const currentHours = (new Date()).getHours();
+
+    const uttranceResult = createUtterance(Utterance).respond(currentHours);

     return handlerInput.responseBuilder
       .speak(uttranceResult.speech)
       .withSimpleCard(
         (uttranceResult.cardTitle as string),
         (uttranceResult.cardContent as string)
       )
       .getResponse();
   },

テストファイルも修正しましょう。

./test/helloWorldUtterance.test.ts
 import * as Mocha from 'mocha';
 import { assert } from 'chai';
 import { HelloWorldUtterance as Utterance } from '../src/utterances/helloWorldUtterance';

 describe('HelloWorldUtterance Class', () => {
   let target: Utterance;

   before(() => {
     target = new Utterance();
   });

-  describe('respond()', () => {
+  describe('respond() GreetingTypes Morning', () => {
     it('発話テキストが一致すること', () => {
-      const result = target.respond();
+      const result = target.respond(6);

-      assert.equal(result.speech, 'Hello World!');
+      assert.equal(result.speech, 'Good morning!');
+    })
+  });
+
+  describe('respond() GreetingTypes Afternoon', () => {
+    it('発話テキストが一致すること', () => {
+      const result = target.respond(12);
+
+      assert.equal(result.speech, 'Good afternoon!');
+    })
+  });
+
+  describe('respond() GreetingTypes Evening', () => {
+    it('発話テキストが一致すること', () => {
+      const result = target.respond(18);
+
+      assert.equal(result.speech, 'Good evening!');
     })
   });
 });

テストも実行します。
それぞれの時間に合わせて挨拶が返ってくることを確認します。

$(npm bin)/mocha --require ts-node/register ./test/helloWorldUtterance.test.ts


  HelloWorldUtterance Class
    respond() GreetingTypes Morning
      ✓ 発話テキストが一致すること
    respond() GreetingTypes Afternoon
      ✓ 発話テキストが一致すること
    respond() GreetingTypes Evening
      ✓ 発話テキストが一致すること


  3 passing (7ms)

まとめ

分離して作成しておくことで複数人での並行開発や他のスキルを作成したときに再利用が可能であるなどメリットもあるかと思います。
これからTypeScriptでスキル開発を始めようとしてる方のお役に立てれば幸いです。

今回の投稿のソースは以下にあります。
alexa-skill-sample-v2 GitHub

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away