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

# はじめに

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

## 前提条件

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

## 設計

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

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

### MVCをざっくり解説

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

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

### Alexa Custom Skillsの場合を考える

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

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

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

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

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

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

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

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

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

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

## サンプルソースの作成

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

```bash
$ ask new
? Please type in your new skill name:  alexa-skill-sample-v2
New project for Alexa skill created.
```

```text
-alexa-skill-sample-v2
  -.ask
    -config
  -lambda
    -custom
      -index.js
  -models
    -en-US.json
  -skill.json
```

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

## TypeScript 開発準備

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

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

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

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

オプションは任意です。
[例) tsconfig.json](https://github.com/daisukeArk/alexa-skill-sample-v2/commit/01b3f6d4cd7dc0ecbb5066611ae62d1f7ec7bcde)

## TypeScript への変換

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

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

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

## 型を定義

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

```diff:./src/index.ts
-const Alexa = require('ask-sdk-core');
+import * as Alexa from 'ask-sdk-core';
```

```diff:./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();
   },
 };
```

[例) 型定義](https://github.com/daisukeArk/alexa-skill-sample-v2/commit/fb49eaf3ad29c9bfee6e15b295152041076afc36#diff-99100afe5a20f7daccfa68eebe657796)

## 実装

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

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

### ハンドラの分離

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

```bash
# 初期ファイルの作成
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`を指定しモジュール化します。

```diff:./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`では、分離したハンドラモジュールをインポートします。

```diff:./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';
```

[例) ハンドラ分離](https://github.com/daisukeArk/alexa-skill-sample-v2/commit/ddef6f97aa085438394021b723cb9248d6262b92)

### 発話内容の分離

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

```bash
# 初期ファイルの作成
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
```

```bash
# 発話内容結果
mkdir -p ./src/entities
touch ./src/entities/utteranceResultBase.ts
```

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

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

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

ここから

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

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

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

```typescript:./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`へ分離した発話クラスをインポートして変更します。

```diff:./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()`はインスタンス作成するために作成した関数です。
以下の書き方でも問題ありません。

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

## テスト＆カバレッジ

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

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

```bash
# インストール
npm i --save-dev mocha chai nyc ts-node @types/mocha @types/chai
```

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

```typescript:./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!');
    })
  });
});
```

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

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

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


  1 passing (5ms)
```

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

```json:./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
  }
}
```

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

```bash
$(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!(上記以外)

の３種類とします。

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

```bash
# ファイルの作成
touch ./src/models/getGreetingType.ts
```

```typescript:./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;
}
```

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

```diff:./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
     };
   }
 }
```

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

```diff:./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();
   },
```

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

```diff:./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!');
     })
   });
 });
```

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

```bash
$(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](https://github.com/daisukeArk/alexa-skill-sample-v2)