TypeScript を使って Alexa Custom Skills を作ろう ASK-SDK for Node.js V2への移行

# はじめに

この投稿では、先日ソース公開した[じゃらじゃら 符計算](https://qiita.com/daisukeArk/items/9accfa61d496cd7ab417)の`Alexa Skills Kit for Node.js V2`へのマイグレーションについて書いていきます。

# ゆるい前提

* 以前投稿していた[TypeScript を使って Alexa Custom Skills を作ろう](https://qiita.com/daisukeArk/items/c22f7f4e4f231d05dd51)に極力合わせて作られていること。

# マイグレーション

## まずは、パッケージ構成情報を変える

### 削除したもの

* alexa-sdk
* @types/alexa-sdk
* alexa-conversation

`alexa-conversation`は、v1のテストフレームワークです。

### 新しく入れたもの

* ask-sdk-core
* ask-sdk-model
* i18next
* i18next-sprintf-postprocessor
* ~~util~~ 標準であるそうです。そちら使うようにしようと思います！
* alexa-conversation-model-assert

v2から型定義も合わせて提供されているのがいいですね！

また、v1では`i18next`による多言語がサポートされていましたが、v2では廃止されました。
`じゃらじゃら`では多言語対応する予定はありませんが、発話内容管理上引き続き利用することとしました。

### diff

```diff
   "dependencies": {
-    "alexa-sdk": "^1.0.0",
-    "config": "^1.29.2",
-    "log4js": "^2.5.2"
+    "ask-sdk-core": "^2.0.3",
+    "ask-sdk-model": "^1.2.0",
+    "config": "^1.30.0",
+    "i18next": "^11.3.1",
+    "i18next-sprintf-postprocessor": "^0.2.2",
+    "log4js": "^2.5.2",
+    "util": "^0.10.3"
   },
   "devDependencies": {
-    "@types/alexa-sdk": "^1.1.1",
     "@types/aws-lambda": "0.0.34",
-    "@types/config": "0.0.33",
+    "@types/config": "0.0.34",
+    "@types/i18next": "^8.4.3",
+    "@types/i18next-sprintf-postprocessor": "0.0.29",
     "@types/mocha": "^5.0.0",
     "@types/node": "^9.6.2",
     "@types/power-assert": "^1.4.29",
     "@types/sinon": "^4.1.3",
-    "alexa-conversation": "^0.2.0",
+    "alexa-conversation-model-assert": "^1.2.0",
```

## インテントハンドラーの作成

v1の頃から、[TypeScript を使って Alexa Custom Skills を作ろう (1) 設計](https://qiita.com/daisukeArk/items/c22f7f4e4f231d05dd51)に合わせて、ある程度ファイルを分割して作成していたので行った作業は以下です。

* インテントファイルの移動、名称変更
* インポートモジュールの変更
* Class定義からオブジェクトへ変更(canHanldle, handleの実装)

### v1のコード

```typescript:/src/intents/help-intent.ts
import * as Alexa from 'alexa-sdk';
import { HelpUtterance as Utterance } from '../utterances/help-utterance';
import { IntentBase } from './intent-base';

/**
 * ヘルプ インテントクラス
 */
export class HelpIntent extends IntentBase<Utterance> {
  /**
   * コンストラクタ
   */
  constructor(utterance: Utterance) {
    super(utterance);
  }

  /**
   * アクション
   * @param context ハンドラコンテキスト
   */
  public execute(context: Alexa.Handler<any>) {
    // 発話取得
    const result = this.utterance.respond(context);

    // レスポンス設定
    context.response
      .speak(result.speech)
      .listen(result.repromptSpeech);

    // レスポンス生成
    context.emit(':responseReady');
  }
}
```

### v2のコード

```typescript:/src/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) {
    return (
      handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
      handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent'
    );
  },
  /**
   * ハンドラ実行
   * @param handlerInput ハンドラ
   */
  handle(handlerInput) {
    // 発話取得
    const speechOutput = createUtterance(Utterance).respond(handlerInput);

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

### diff

以下の理由で比較的簡単に移行できました。

* 元々ラッパーを利用していなかったこと(ask, tell)
* 発話内容は外部モジュールで実装していたこと(utteranceクラス)
* ヘルプインテント自体がシンプルである

```diff
-import * as Alexa from 'alexa-sdk';
+import * as Ask from 'ask-sdk-core';
+import { createUtterance } from '../factories/utterance-factory';
 import { HelpUtterance as Utterance } from '../utterances/help-utterance';
-import { IntentBase } from './intent-base';
 
 /**
- * ヘルプ インテントクラス
+ * ヘルプ インテントハンドラ
  */
-export class HelpIntent extends IntentBase<Utterance> {
+export const HelpIntentHandler: Ask.RequestHandler = {
   /**
-   * コンストラクタ
+   * 実行判定
+   * @param handlerInput ハンドラ
    */
-  constructor(utterance: Utterance) {
-    super(utterance);
-  }
-
+  canHandle(handlerInput) {
+    return (
+      handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
+      handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent'
+    );
+  },
   /**
-   * アクション
-   * @param context ハンドラコンテキスト
+   * ハンドラ実行
+   * @param handlerInput ハンドラ
    */
-  public execute(context: Alexa.Handler<any>) {
+  handle(handlerInput) {
     // 発話取得
-    const result = this.utterance.respond(context);
+    const speechOutput = createUtterance(Utterance).respond(handlerInput);
 
-    // レスポンス設定
-    context.response
-      .speak(result.speech)
-      .listen(result.repromptSpeech);
-
-    // レスポンス生成
-    context.emit(':responseReady');
-  }
+    // レスポンス
+    return handlerInput.responseBuilder
+      .speak(speechOutput.speech)
+      .reprompt(speechOutput.repromptSpeech)
+      .getResponse();
   }
+};
```

## 発話(Utterance)クラスの移行

行った作業は以下です。

* インポートモジュールの変更
* 引数の名称と型を変更

### v1のコード

```typescript:/src/utterances/help-utterance.ts
import * as Alexa from 'alexa-sdk';
import { IUtteranceResult } from '../domains/utterance-result';
import { UtteranceBase } from './utterance-base';

/**
 * ヘルプ 発話クラス
 */
export class HelpUtterance extends UtteranceBase {
  /**
   * コンストラクタ
   */
  constructor() {
    super();
  }

  /**
   * 発話内容取得
   * @param context ハンドラコンテキスト
   * @returns 発話結果
   */
  public respond(context: Alexa.Handler<any>): IUtteranceResult {
    return {
      speech: <any>context.t('HELP_MESSAGE') + <any>context.t('HELP_FU_NUMBER') + <any>context.t('HELP_HAN_NUMBER'),
      repromptSpeech: <any>context.t('HELP_MESSAGE')
    };
  }
}
```

### v2のコード

```typescript:/src/utterances/help-utterance.ts
import * as Ask from 'ask-sdk-core';
import { IHelpSpeechOutput as ISpeechOutput } from './domains/help-speech-output';
import { UtteranceBase } from './utterance-base';

/**
 * ヘルプ 発話クラス
 */
export class HelpUtterance extends UtteranceBase {
  /**
   * コンストラクタ
   */
  constructor() {
    super();
  }

  /**
   * 発話内容取得
   * @param handlerInput ハンドラコンテキスト
   * @returns 発話内容
   */
  public respond(handlerInput: Ask.HandlerInput): ISpeechOutput {
    const requestAttributes = handlerInput.attributesManager.getRequestAttributes();

    return {
      speech: requestAttributes.t('HELP_MESSAGE') + requestAttributes.t('HELP_FU_NUMBER') + requestAttributes.t('HELP_HAN_NUMBER'),
      repromptSpeech: requestAttributes.t('HELP_MESSAGE')
    };
  }
}
```

### diff

```diff
-import * as Alexa from 'alexa-sdk';
-import { IUtteranceResult } from '../domains/utterance-result';
+import * as Ask from 'ask-sdk-core';
+import { IHelpSpeechOutput as ISpeechOutput } from './domains/help-speech-output';
 import { UtteranceBase } from './utterance-base';

   /**
    * 発話内容取得
-   * @param context ハンドラコンテキスト
-   * @returns 発話結果
+   * @param handlerInput ハンドラコンテキスト
+   * @returns 発話内容
    */
-  public respond(context: Alexa.Handler<any>): IUtteranceResult {
+  public respond(handlerInput: Ask.HandlerInput): ISpeechOutput {
+    const requestAttributes = handlerInput.attributesManager.getRequestAttributes();
+
     return {
-      speech: <any>context.t('HELP_MESSAGE') + <any>context.t('HELP_FU_NUMBER') + <any>context.t('HELP_HAN_NUMBER'),
-      repromptSpeech: <any>context.t('HELP_MESSAGE')
+      speech: requestAttributes.t('HELP_MESSAGE') + requestAttributes.t('HELP_FU_NUMBER') + requestAttributes.t('HELP_HAN_NUMBER'),
+      repromptSpeech: requestAttributes.t('HELP_MESSAGE')
     };
   }
 }
```

インテントハンドラ同様、比較的変更箇所も少なく移行できました。

## 多言語対応

次に廃止された`i18next`を組み込みます。

### インターセプターの作成

以下のソースを参考にしています。

[https://github.com/alexa/skill-sample-nodejs-howto/blob/master/lambda/custom/index.js#L214-L229](https://github.com/alexa/skill-sample-nodejs-howto/blob/master/lambda/custom/index.js#L214-L229)

```typescript:/src/interceptors/common-interceptor.ts
import * as Ask from 'ask-sdk-core';
import * as i18n from 'i18next';
import * as sprintf from 'i18next-sprintf-postprocessor';
import { languageStrings } from '../utterances/language-strings';

export const CommonInterceptor: Ask.RequestInterceptor = {
  process(handlerInput) {
    const localizationClient = i18n.use(sprintf).init({
      lng: handlerInput.requestEnvelope.request.locale,
      overloadTranslationOptionHandler: sprintf.overloadTranslationOptionHandler,
      resources: languageStrings,
      returnObjects: true
    });

    const attributes = handlerInput.attributesManager.getRequestAttributes();
    attributes.t = (key: string | string[], options?: i18n.TranslationOptions<object> | undefined) => {
      return localizationClient.t(key, options);
    };
  }
};
```

さらにローカライズファイルも変更します。
今までは、`%s`により動的に補完していましたが、パラメータ毎に任意の名称を付けるよう変更しました。

こんな感じ。

* pointParent: 親の点数
* pointChildren: 子の点数

```diff
-      'ANSWER_CHILD_TSUMO': 'ツモは親が<break time="100ms"/>%s点、子が<break time="100ms"/>%s点です。',
+      'ANSWER_CHILD_TSUMO': 'ツモは親が<break time="100ms"/>{{pointParent}}点、子が<break time="100ms"/>{{pointChildren}}点です。',
```

## Lambdaハンドラーの移行

最後にLambdaハンドラーの移行です。

* インポートモジュールの変更
* V2に合わせハンドラを追加
* 環境変数を`APP_ID`から`ALEXA_SKILL_ID`へ変更（これは必須ではないです。解りやすくする為)

### v1のコード

```typescript:/src/index.ts
import * as Alexa from 'alexa-sdk';
import * as Handlers from './handlers';
import { languageStrings } from './utterances/language-strings';

/**
 * エントリポイント
 * @param event イベント
 * @param context コンテキスト
 * @param callback コールバック
 */
export const handler = (
  event: Alexa.RequestBody<any>,
  context: Alexa.Context,
  callback: (err: any, response: any) => void
) => {

  const alexa = Alexa.handler(event, context, callback);
  if (process.env.APP_ID) {
    alexa.appId = process.env.APP_ID;
  }
  alexa.resources = languageStrings;
  alexa.registerHandlers(Handlers.DefaultHandler);
  alexa.execute();
};
```

### v2のコード

```typescript:/src/index.ts
import * as Ask from 'ask-sdk-core';
import * as Handlers from './handlers';
import * as Interceptors from './interceptors';

/**
 * エントリポイント
 */
export const handler = Ask.SkillBuilders.custom()
.addRequestHandlers(
  Handlers.CalculatePointIntentHandler,
  Handlers.CancelAndStopIntentHandler,
  Handlers.HelpIntentHandler,
  Handlers.LaunchRequestHandler,
  Handlers.SessionEndedRequestHandler
)
.addRequestInterceptors(Interceptors.CommonInterceptor)
.addErrorHandlers(
  Handlers.ErrorHandler
)
.withSkillId(String(process.env.ALEXA_SKILL_ID))
.lambda();
```

### diff

```diff
-import * as Alexa from 'alexa-sdk';
+import * as Ask from 'ask-sdk-core';
 import * as Handlers from './handlers';
-import { languageStrings } from './utterances/language-strings';
+import * as Interceptors from './interceptors';
 
 /**
  * エントリポイント
- * @param event イベント
- * @param context コンテキスト
- * @param callback コールバック
  */
-export const handler = (
-  event: Alexa.RequestBody<any>,
-  context: Alexa.Context,
-  callback: (err: any, response: any) => void
-) => {
-
-  const alexa = Alexa.handler(event, context, callback);
-  if (process.env.APP_ID) {
-    alexa.appId = process.env.APP_ID;
-  }
-  alexa.resources = languageStrings;
-  alexa.registerHandlers(Handlers.DefaultHandler);
-  alexa.execute();
-};
+export const handler = Ask.SkillBuilders.custom()
+.addRequestHandlers(
+  Handlers.CalculatePointIntentHandler,
+  Handlers.CancelAndStopIntentHandler,
+  Handlers.HelpIntentHandler,
+  Handlers.LaunchRequestHandler,
+  Handlers.SessionEndedRequestHandler
+)
+.addRequestInterceptors(Interceptors.CommonInterceptor)
+.addErrorHandlers(
+  Handlers.ErrorHandler
+)
+.lambda();
```

`じゃらじゃら`スキルは機能がシンプルなので、指定方法が変わったくらいなのであまり特筆することが無いのです。。。

強いて言うなら、
今まで状態毎に作成していたハンドラは、定義されたインテントを`addRequestHandlers`に登録していくことになります。
状態遷移が多いスキルでは、この部分が大きく移行コストへ影響すると思います。

例えば、以下のようにセッションの状態などを`CanHandle`関数へ細かく定義していくことになります。

```typescript
  /**
   * 実行判定
   * @param handlerInput ハンドラ
   */
  canHandle(handlerInput) {
    const session = getSession(handlerInput);

    return (
      handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
      handlerInput.requestEnvelope.request.intent.name === 'AMAZON.NextIntent' &&
      session.state === '_STATUS' &&
      session.xxxxxx !== undefined
    );
  },
```

## ついでにランタイムバージョンも変更してみた

AWS Lambdaのランタイムバージョンを`v6.10`から`v8.10`へ変更しました。

* ターゲットを変更

### ターゲットの変更

```diff:tsconfig.json
   "compilerOptions": {
-    "target": "ES2015",
+    "target": "ES2017",
```

# まとめ

`ASK SDK for Node.js V2`の移行について、どのように移行していったかを簡単に纏めました。

`じゃらじゃら`スキルは状態を持っていないので、比較的簡単に移行ができました。
肝の点数計算インテントはソース公開しているのでそちらをご覧ください。
移行について何かあればまた別記事で共有したいと思います。

v2のソースコードは以下にあります。

[じゃらじゃら 符計算 v2 - GitHub](https://github.com/daisukeArk/alexa-jarajara-node/tree/v2.0.1)

[i18next使わないバージョン](https://github.com/daisukeArk/alexa-jarajara-node/tree/v2.1.0)

# お知らせ(宣伝)

手前味噌ではありますが、v2用のテストフレームワークを作りましたので良ければお使いください。

[ASK SDK v2 for Node.jsのテストフレームワークが欲しい（作ってみた） - Qiita](https://qiita.com/daisukeArk/items/d8aa2c38d33c1670f4b8)
[alexa-conversation-model-assert - npm](https://www.npmjs.com/package/alexa-conversation-model-assert)
[alexa-conversation-model-assert - GitHub](https://github.com/daisukeArk/alexa-conversation-model-assert)