TypeScript を使って Clova Custom Extension を作ろう - Express

# はじめに

Clova Extensions Kit 出ましたね！

Clova Extensions Kit ではSDKが型定義を提供しているので[TypeScript を使って Alexa Custom Skills を作ろう](https://qiita.com/daisukeArk/items/ac45f10d2fc8fe8bdc5a)に続き、Clova Extension も TypeScript で実装していきましょう！

本投稿では、[TypeScript を使って Alexa Custom Skills を作ろう ASK-SDK for Node.js V2への移行](https://qiita.com/daisukeArk/items/edd44852dce3c04a2277)で作成したプログラムを元に極力活かす形で変更を加えながら、実装をしていきます。

ソースはこちらを元にします。

[daisukeArk/alexa-jarajara-node v2.1.0 GitHub](https://github.com/daisukeArk/alexa-jarajara-node/tree/v2.1.0)
[line/clova-cek-sdk-nodejs examples/echo - GitHub ](https://github.com/line/clova-cek-sdk-nodejs)

## 目次

* 「TypeScript を使って Alexa Custom Skills を作ろう」のおさらい
* 実装する機能について
* 実装
    * 依存パッケージについて
    * 発話セットの作成
    * 発話クラスの作成
    * ハンドラの作成
    * インテントハンドラの組み立て
* トランスパイル
* ローカルテスト

## 「TypeScript を使って Alexa Custom Skills を作ろう」のおさらい

### ディレクトリ構成

MVCフレームワークのように構造大きく３つに分割した構成とした。

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

```text:/lambda/custom
lambda
└── custom
  ├── src
  | ├── enums                                   … 列挙型
  | ├── handlers                                … インテントハンドラ
  | ├── helpers                                 … ヘルパー（ユーティリティとか）
  | ├── intents                                 … インテント(SDK V2が出てから使わなくなった)
  | ├── models                                  … モデル
  | └── utterances                              … 発話
  └── test                                      … 単体テスト
```

本投稿のサンプルでは、Expressを利用するため以下のディレクトリ構造としました。

```text:/express/www
express
└── www
  ├── src
  | ├── enums                                   … 列挙型
  | ├── handlers                                … インテントハンドラ
  | ├── helpers                                 … ヘルパー（ユーティリティとか）
  | ├── models                                  … モデル
  | ├── extension                               … 拡張
  | └── utterances                              … 発話
  └── test                                      … 単体テスト
```

## 実装する機能について

[daisukeArk/alexa-jarajara-node v2.1.0 GitHub](https://github.com/daisukeArk/alexa-jarajara-node/tree/v2.1.0)のソースを流用し、以下のハンドラを実装します。
必要の無いファイルは基本削除します。

* LaunchRequest - 起動要求
* HelloWorldIntent - カスタムインテント
* GuideIntent - ガイド
* Unhandled - どのインテントにも該当しない
* SessionEndedRequest - セッション終了要求
* ErrorHandle - 例外ハンドラ

`HelloWorldIntent`では、ビルトインスロットの`CLOVA.JA_ADDRESS_KEN`を利用し、
「こんにちは　東京都」と言われたら、オウム返しします。

## 実装

### 依存パッケージの変更について

[clova-cek-sdk-nodejs](https://github.com/line/clova-cek-sdk-nodejs) にある examples/echoを参考に以下の３つを追加します。他のパッケージは私の好みなので任意です。

#### 追加

* @line/clova-cek-sdk-nodejs
* body-parser
* express

#### 削除

* ask-sdk-core
* ask-sdk-model

#### 変更後

```json-doc:package.json
  "dependencies": {
    "@line/clova-cek-sdk-nodejs": "^1.0.1", // 必須
    "body-parser": "^1.18.2",               // 必須
    "config": "^1.30.0",                    // 任意　環境によって設定を切り替える
    "express": "^4.16.3",                   // 必須
    "log4js": "^2.5.2",                     // 任意　ロガー
    "underscore": "^1.9.1"                  // 任意　会話を動的に組み立てるのに利用(template)
  },
```

### 発話セットの作成

Clovaが発話する内容はすべて１ファイルで管理します。
underscoreモジュールのtemplateを利用し、会話を動的に組み立てます。

```typescript:utterances/language-strings.ts
export const languageStrings: ILanguageStrings = {
  ja: {
    'WELCOME': 'ようこそ',
    'HELLO_WORLD': 'こんにちは、<%= customSlot %>',
    'GUIDE': 'こんにちは、東京都。など挨拶のあとに都道府県名を言ってください。',
    'UNHANDLED_MESSAGE': 'ごめんなさい、よく聞きとれませんでした。',
    'ANYTHING_ELSE': '他に何かご用ですか？',
    'RETRY': 'もう一度、言ってください。'
  }
};

export interface ILanguageStrings {
  ja: {
    'WELCOME': string;
    'HELLO_WORLD': string;
    'GUIDE': string;
    'UNHANDLED_MESSAGE': string;
    'ANYTHING_ELSE': string;
    'RETRY': string;
  };
}
```

### 発話クラスの作成

ここからは、型定義を変更しSDKに合わせて呼び出すメソッドなどを置き換えていきます。
発話クラスでは、ハンドラから渡されたスロット値などを元に発話を組み立てて、その結果を返します。

#### ガイド（ヘルプ）インテントの発話クラスの修正

##### Before

```typescript:utterances/help-utterance.ts
import { IHelpSpeechOutput as ISpeechOutput } from './domains/help-speech-output';
import { ILanguageStrings } from './language-strings';
import { UtteranceBase } from './utterance-base';

/**
 * ヘルプ 発話クラス
 */
export class HelpUtterance extends UtteranceBase {
  /**
   * コンストラクタ
   * @param languageStrings 発話セット
   */
  constructor(languageStrings: ILanguageStrings) {
    super(languageStrings);
  }

  /**
   * 発話内容取得
   * @returns 発話内容
   */
  public respond(): ISpeechOutput {
    return {
      speech: this.languageStrings.ja.HELP_MESSAGE + this.languageStrings.ja.HELP_FU_NUMBER + this.languageStrings.ja.HELP_HAN_NUMBER,
      repromptSpeech: this.languageStrings.ja.HELP_MESSAGE
    };
  }
}
```

##### After

ASK SDK に依存した作りとなっていないクラスなので、修正箇所は少ないです。

* ファイル名、クラス名の変更
* 発話する内容の変更

```typescript:utterances/guide-utterance.ts
import { IGuideSpeechOutput as ISpeechOutput } from './domains/guide-speech-output';
import { ILanguageStrings } from './language-strings';
import { UtteranceBase } from './utterance-base';

/**
 * ガイド 発話クラス
 */
export class GuideUtterance extends UtteranceBase {
  /**
   * コンストラクタ
   * @param languageStrings 発話セット
   */
  constructor(languageStrings: ILanguageStrings) {
    super(languageStrings);
  }

  /**
   * 発話内容取得
   * @returns 発話内容
   */
  public respond(): ISpeechOutput {
    return {
      speech: this.languageStrings.ja.GUIDE,
      repromptSpeech: this.languageStrings.ja.GUIDE
    };
  }
}
```

#### こんにちは世界インテントの発話クラスの作成

こちらは新規作成します。

* スロット値引数の型定義を設定
* スロット値（都道府県名）を受け取り、それを元に発話内容を返す

```typescript:utterances/hello-world-utterance.ts
import * as Clova from '@line/clova-cek-sdk-nodejs';
import * as __ from 'underscore';
import { IHelloWorldSpeechOutput as ISpeechOutput } from './domains/hello-world-speech-output';
import { ILanguageStrings } from './language-strings';
import { UtteranceBase } from './utterance-base';

/**
 * こんにちは世界 発話クラス
 */
export class HelloWorldUtterance extends UtteranceBase {
  /**
   * コンストラクタ
   * @param languageStrings 発話セット
   */
  constructor(languageStrings: ILanguageStrings) {
    super(languageStrings);
  }

  /**
   * 発話内容取得
   * @param customSlot 都道府県スロット
   * @returns 発話内容
   */
  public respond(customSlot: Clova.Clova.SlotValue): ISpeechOutput {
    const speechText = __.template(this.languageStrings.ja.HELLO_WORLD)({
      customSlot: customSlot
    });

    return {
      speech: speechText,
      repromptSpeech: this.languageStrings.ja.ANYTHING_ELSE
    };
  }
}
```

### ハンドラの作成

Alexa Skills Kit SDK for Node.js V2 と同様にハンドラを作成します。
V2とほぼ同様の作りなのが分かるかと思います。

違いがあるのは、以下の5つですね。

* import
* 型
* インテント名の取得
* スロット値の取得
* レスポンスの組み立て

#### ガイド（ヘルプ）インテントハンドラの修正

##### Before

```typescript:handlers/help-intent-handler.ts
import * as Ask from 'ask-sdk-core';
import { createUtterance } from '../factories/utterance-factory';
import { HelpUtterance as Utterance } from '../utterances/help-utterance';

/**
 * ヘルプ インテントハンドラ
 */
export const HelpIntentHandler: Ask.RequestHandler = {
  /**
   * 実行判定
   * @param handlerInput ハンドラ
   */
  canHandle(handlerInput: Ask.HandlerInput) {
    return (
      handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
      handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent'
    );
  },
  /**
   * ハンドラ実行
   * @param handlerInput ハンドラ
   */
  handle(handlerInput: Ask.HandlerInput) {
    // 発話取得
    const speechOutput = createUtterance(Utterance).respond();

    // レスポンス
    return handlerInput.responseBuilder
      .speak(speechOutput.speech)
      .reprompt(speechOutput.repromptSpeech)
      .getResponse();
  }
};
```

##### After

```typescript:handlers/guide-intent-handler.ts
import * as Clova from '@line/clova-cek-sdk-nodejs';
import { RequestHandler } from '../extension/clova-extension-client';
import { createUtterance } from '../factories/utterance-factory';
import { GuideUtterance as Utterance } from '../utterances/guide-utterance';

/**
 * ガイド インテントハンドラ
 */
export const GuideIntentHandler: RequestHandler = {
  /**
   * 実行判定
   * @param handlerInput ハンドラ
   */
  canHandle(handlerInput: Clova.Context) {
    return (
      handlerInput.getIntentName() === 'Clova.GuideIntent'
    );
  },
  /**
   * ハンドラ実行
   * @param handlerInput ハンドラ
   */
  handle(handlerInput: Clova.Context) {
    // 発話取得
    const speechOutput = createUtterance(Utterance).respond();

    // レスポンス設定
    handlerInput
      .setSimpleSpeech(Clova.SpeechBuilder.createSpeechText(speechOutput.speech))
      .setSimpleSpeech(Clova.SpeechBuilder.createSpeechText(speechOutput.repromptSpeech), true);
  }
};
```

#### こんにちは世界インテントハンドラの作成

```typescript:handlers/hello-world-intent-handler.ts
import * as Clova from '@line/clova-cek-sdk-nodejs';
import { createUtterance } from '../factories/utterance-factory';
import { RequestHandler } from '../helpers/clova-skill';
import { HelloWorldUtterance as Utterance } from '../utterances/hello-world-utterance';

/**
 * こんにちは世界 インテントハンドラ
 */
export const HelloWorldIntentHandler: RequestHandler = {
  /**
   * 実行判定
   * @param handlerInput ハンドラ
   */
  canHandle(handlerInput: Clova.Context) {
    return (
      handlerInput.getIntentName() === 'HelloWorldIntent' &&
      handlerInput.getSlot('customSlot') !== null
    );
  },
  /**
   * ハンドラ実行
   * @param handlerInput ハンドラ
   */
  handle(handlerInput: Clova.Context) {
    // スロット収集
    const customSlot = handlerInput.getSlot('customSlot');

    // 発話取得
    const speechOutput = createUtterance(Utterance).respond(customSlot);

    // レスポンス設定
    handlerInput
      .setSimpleSpeech(Clova.SpeechBuilder.createSpeechText(speechOutput.speech))
      .setSimpleSpeech(Clova.SpeechBuilder.createSpeechText(speechOutput.repromptSpeech), true);
  }
};
```

### インテントハンドラの組み立て

ここまではほぼAlexaと同様でした。
ClovaでもAlexaと同じ様にインテントハンドラを呼び出せるように拡張クライアントプログラムを作成します。

#### 拡張クライアント

コードは[こちら](https://github.com/daisukeArk/clova-extension-sample-node-express/blob/master/skill/express/www/src/extension/clova-extension-client.ts)

* インテントハンドラ、エラーハンドラを登録するメソッドを実装
* 登録されたインテントハンドラを呼出すメソッドを実装

##### インテントハンドラ、エラーハンドラを登録するメソッドを実装

```typescript:extension/clova-extension-client.ts
  /**
   * インテントハンドラ追加
   * @param requestHandlers インテントハンドラ
   */
  public addRequestHandlers(...handlers: RequestHandler[]): this {
    for (const handler of handlers) {
      this.handlers.push(handler);
    }
    return this;
  }

  /**
   * エラーハンドラ追加
   * @param errorHandlers エラーハンドラ
   */
  public addErrorHandlers(...handlers: IErrorHandler[]): this {
    for (const handler of handlers) {
      this.errorHandlers.push(handler);
    }

    return this;
  }
```

##### 登録されたインテントハンドラを呼出すメソッドを実装

```typescript:extension/clova-extension-client.ts
  /**
   * ハンドラ呼出
   * @param handlerInput コンテキスト
   */
  public async invoke(handlerInput: Clova.Context): Promise<void> {
    try {
      let target: RequestHandler | null = null;

      for (const requestHandler of this.handlers) {
        if (await requestHandler.canHandle(handlerInput)) {
          target = requestHandler;
          break;
        }
      }

      if (target == null) {
        throw Error('Not Found IntentHandler.');
      }
      await target.handle(handlerInput);
      return;
    } catch (error) {
      if (this.errorHandlers.length > 0) {
        let errorHandler: IErrorHandler | null = null;

        for (const handler of this.errorHandlers) {
          if (await handler.canHandle(handlerInput, error)) {
            errorHandler = handler;
          }
        }

        if (errorHandler) {
          await errorHandler.handle(handlerInput, error);
          return;
        }
      }

      throw error;
    }
  }
```

#### 組み立て

```javascript:line/clova-cek-sdk-nodejsのサンプル抜粋
const launchHandler = async responseHelper => {
  responseHelper.setSimpleSpeech(
    SpeechBuilder.createSpeechText('おはよう')
  );
};

const intentHandler = async responseHelper => {
  const intent = responseHelper.getIntentName();
  const sessionId = responseHelper.getSessionId();

  switch (intent) {
    case 'Clova.YesIntent':
      responseHelper.setSimpleSpeech(
        SpeechBuilder.createSpeechText('はいはい')
      );
      break;
    case 'Clova.NoIntent':
      responseHelper.setSimpleSpeech(
        SpeechBuilder.createSpeechText('いえいえ')
      );
      break;
    default:
      responseHelper.setSimpleSpeech(
        SpeechBuilder.createSpeechText('なんなん')
      );
      break;
  }
};

const sessionEndedHandler = async responseHelper => {};

const clovaHandler = Client
  .configureSkill()
  .onLaunchRequest(launchHandler)
  .onIntentRequest(intentHandler)
  .onSessionEndedRequest(sessionEndedHandler)
  .handle();
```

カスタム拡張クライアントを利用したあとのコードはこのような感じになります。

* 拡張クライアントにインテントハンドラを登録する
* エントリポイントで`onIntentRequest`に紐付ける
* `LaunchRequest`, `SessionEndedRequest`はhandleメソッドを直接指定

```typescript:handlers/intent-handlers.ts
import * as Clova from '@line/clova-cek-sdk-nodejs';
import * as Handlers from '../handlers';
import { ClovaExtensionClient } from '../extension/clova-extension-client';

export const intentHandlers = async (responseHelper: Clova.Context) => {
  const clova = new ClovaExtensionClient()
    .addRequestHandlers(
      Handlers.GuideIntentHandler,
      Handlers.HelloWorldIntentHandler,
      Handlers.UnhandledHandler
    ).addErrorHandlers(
      Handlers.ErrorHandler
    );

  await clova.invoke(responseHelper);
};
```

```typescript:index.ts
const clovaHandler = Clova.Client
  .configureSkill()
  .onLaunchRequest(Handlers.LaunchRequestHandler.handle)
  .onIntentRequest(Handlers.intentHandlers)
  .onSessionEndedRequest(Handlers.SessionEndedRequestHandler.handle)
  .handle();
```

## トランスパイル

TypeScriptで実装して、いざトランスパイルしようとしたときに失敗する方もいるかもしれません。
[PR#2](https://github.com/line/clova-cek-sdk-nodejs/pull/2)出したんですが、`tsconfig.json`で`allowSyntheticDefaultImports: true`を指定しないとトランスパイルがこけます。

```bash
node_modules/@line/clova-cek-sdk-nodejs/dist/types/types.d.ts:1:8 -
error TS1192: Module '"~/clova-extension-sample-node-express/skill/express/www/node_modules/@types/express/index"'
has no default export.

1 import express from 'express';
         ~~~~~~~
```

`allowSyntheticDefaultImports`は何者かというと

> Allow default imports from modules with no default export. This does not affect code emit, just typechecking.

> export defaultが指定されていないモジュールでも上記のエラー箇所のような書き方でもインポートを許可するように指定できるものです。
> デフォルトで`false`になっているのでこけるわけです。

そしてなぜ`node_modules`配下も型チェックされるかというと、
http://js.studio-kingdom.com/typescript/project_configuration/tsconfig_json

> "files"または"include"に指定されたファイルによって参照されるファイルも含まれます。 同様に、ファイルB.tsが別のファイルA.tsに参照される場合、 A.tsも"exclude"の一覧に指定されない限り、B.tsを除外することはできません。

SDKを参照(import)しているので、excludeで`node_modules`を除外していてもトランスパイルの対象に含まれてしまうみたいなんですね。

なので、どう対応となるかはわかりませんが`allowSyntheticDefaultImports: true`を入れましょう。

## ローカルテスト

トランスパイルまで終わったら、実行してみましょう。
ここでは、curlコマンドで結果を確認します。

### 起動

```bash
$ nodebrew ls
v8.10.0

current: v8.10.0

$ node ./dist/src/index.js
Server running on 3000
```

### リクエスト本文JSON

```json:RequestBody
{
    "version": "1.0",
    "session": {
        "sessionId": "xxxxx",
        "user": {
            "userId": "xxxxx",
            "accessToken": "xxxxx"
        },
        "new": true
    },
    "context": {
        "System": {
            "application": {
                "applicationId": "sample"
            },
            "user": {
                "userId": "xxxxx",
                "accessToken": "xxxxx"
            },
            "device": {
                "deviceId": "xxxxx",
                "display": {
                    "size": "l100",
                    "orientation": "landscape",
                    "dpi": 96,
                    "contentLayer": {
                        "width": 640,
                        "height": 360
                    }
                }
            }
        }
    },
    "request": {
        "type": "IntentRequest",
        "intent": {
            "name": "HelloWorldIntent",
            "slots": {
            	"customSlot": {
            		"name": "customSlot",
            		"value": "東京都"
            	}
        	}
        }
    }
}
```

### テスト

こんな感じで返ってくれば成功です！

```bash
$ curl -d @test/fixtures/hello-world-intent-request.json -H "Content-Type: application/json" http://localhost:3000/clova | jq .

{
  "response": {
    "card": {},
    "directives": [],
    "outputSpeech": {
      "type": "SimpleSpeech",
      "values": {
        "lang": "ja",
        "type": "PlainText",
        "value": "こんにちは、東京都"
      }
    },
    "shouldEndSession": false,
    "reprompt": {
      "outputSpeech": {
        "type": "SimpleSpeech",
        "values": {
          "lang": "ja",
          "type": "PlainText",
          "value": "他に何かご用ですか？"
        }
      }
    }
  },
  "version": "1.0"
}
```

## まとめ

Alexaで作成したプログラムを元にClova Custom Extensionの実装からローカルテストまでを行いました。
Clova Extensionの機能も情報もこれからどんどん増えていくでしょうから楽しみですね！
（TypeScript使いももっと増えるといいなー）

今回のソースは以下にあります。

[daisukeArk/clova-extension-sample-node-express - GitHub](https://github.com/daisukeArk/clova-extension-sample-node-express)

## 参考

すごい丁寧に纏めてくれているので参考になりました！

[[入門]Clovaスキル(CEK)は作りながら覚えて行く（前提知識編） - Qiita](https://qiita.com/imajoriri/items/8d511cdfde8cb731ff82)