Node.js
TypeScript
Alexa
AlexaSkillsKit

TypeScript を使って Alexa Custom Skills を作ろう (5) 実装 - 発話

前回

前回の投稿では、ハンドラにあるインテントの外部モジュール化を行いました。
いよいよ、実装編は最後になります。残りは発話についてです。

はじめに

Alexa スキルは日本以外の他の国でも提供できるように、スキル毎に複数の言語が選択できるようになっています。
alexa-skills-kit-sdk-for-nodejsにはローカライズする機能がi18next実装されています。

将来的に多言語対応することを見据えて、本投稿でもこの機能を利用する前提で進めていきます。(サンプルスキルでは既に日本語だけ利用しています。)

さて、今回の投稿では、発話を外部モジュール化していきます。

さいごのじっそうだ

index.tsfirst-handler.tsで実装されている発話部分を外部モジュール化します。

index.ts(抜粋)
const languageStrings = {
  'ja-JP': {
    'translation': {
      // ~~~省略~~~
    }
  }
};
first-handler.ts(抜粋)
'FirstIntent': function (this: Alexa.Handler<any>) {
  var sayNumber = this.event.request.intent.slots.number.value;

  if (!sayNumber || !newsContents.hasOwnProperty(sayNumber)) {
    this.emitWithState('Unhandled');
  }

  getNewsAsync(sayNumber)
    .then((content) => {
      this.emit(':ask', this.t('ASK_ANSWER_NUMBER', content), his.t('ASK_ANSWER_NUMBER_REPROMPT'));
    })
    .catch((error) => {
      this.emitWithState("Unhandled");
    });
},

まずは、発話ディレクトリをつくろう

移動
$ cd ~/custom-skill-sample-to-convert/skill/lambda/custom/src
utterancesディレクトリ作成
$ mkdir -p utterances

i18nextに食わせるローカライズデータを外出しする

index.tsにそのまま置き去りにしていたローカライズデータ(languageStrings変数)を外出ししてあげましょう。

ローカライズファイル作成(i18nextに食わせるもの)
$ touch ./utterances/language-strings.ts
utterances/language-strings.ts
export const languageStrings = {
  'ja-JP': {
    'translation': {
      'TELL_WELCOME_MESSAGE': 'タイろうへようこそ。',
      'ASK_HELP_MESSAGE': '声を聞かせて、と言ってください。',
      'ASK_START_MODE': 'ファーストモードに変更します。数字を言ってください。',
      'ASK_START_MODE_REPROMPT': '数字を言ってください。',
      'ASK_UNHANDLED_MESSAGE': 'すみません、よく聞きとれませんでした。もう一回言ってください。',
      'TELL_GOOD_BYE': 'さようなら',
      'TELL_UNHANDLED_MESSAGE': 'すみません、よく聞きとれませんでした。',
      'ASK_ANSWER_NUMBER': '%s。他に聞きたい数字はありますか?',
      'ASK_ANSWER_NUMBER_REPROMPT': '数字を言ってください。',
      'TELL_HELLO_WORLD': 'こんにちは、世界',
      'ASK_ANYTHING_ELSE': '他になにかありますか?',
    }
  }
};

これはもう、カット&ペーストしてエクスポートするだけなので単純ですね。
インポートも忘れずに。

index.ts
import { languageStrings } from './utterances/language-strings';

発話クラスの作成

発話クラスでは、FirstIntentの以下の部分で必要な最初の発話追加の発話を返す機能を実装します。

first-handler.ts(抜粋)
getNewsAsync(sayNumber)
  .then((content) => {
    this.emit(':ask', this.t('ASK_ANSWER_NUMBER', content), this.t('ASK_ANSWER_NUMBER_REPROMPT'));
  })
  .catch((error) => {
    this.emitWithState("Unhandled");
  });

準備

まずは、ファイルを準備しましょう。
発話クラスもインテント同様、コンテキストに依存する為基底クラスを作成します。

発話基底クラスの作成
$ touch ./utterances/utterance-base.ts
発話基底クラス
import * as Alexa from 'alexa-sdk';

/**
 * 発話基底クラス
 */
export abstract class UtteranceBase {
  /**
   * プロパティ - Alexaハンドラコンテキスト
   */
  protected alexaContext: Alexa.Handler<any>;

  /**
   * コンストラクタ
   * @param context Alexaハンドラコンテキスト
   */
  constructor(context: Alexa.Handler<any>) {
    this.alexaContext = context;
  }
}

では、これを基に発話クラスを実装していきましょう。
まずは、形だけ作ります。

発話クラスの作成
$ touch ./utterances/first-utterance.ts
utterances/first-utterance.ts
import * as Alexa from 'alexa-sdk';
import { UtteranceBase } from './utterance-base';

/**
 * ファースト発話クラス
 */
export class FirstUtterance extends UtteranceBase {
  /**
   * コンストラクタ
   * @param context Alexaハンドラコンテキスト
   */
  constructor(context: Alexa.Handler<any>) {
    super(context);
  }

  /**
   * 発話内容取得
   */
  public respond() {

  }
}

発話クラスのrespondメソッドを実装する

準備が終わったので、respondメソッドを実装していきます。
せっかく、TypeScriptを使うのでインタフェースも使っていきましょう。

respondメソッドで最初の発話追加の発話を返す為には、ユーザから発話された数字が必要になります。
ここでは、2つのインタフェースが必要になります。

  • 発話内容を得る為の条件(ユーザが発話した数字
  • 発話内容(最初の発話追加の発話

これらをインタフェースで定義します。

conditionsディレクトリ作成
$ mkdir -p conditions
modelsディレクトリ作成
$ mkdir -p models
条件インタフェースの作成
$ touch ./conditions/first-utterance-condition.ts
発話内容インタフェースの作成
$ touch ./models/first-utterance-result.ts
models/first-utterance-condition.ts
/**
 * 発話条件 インタフェース
 */
export interface IFirstUtteranceCondition {
  /**
   * 数字
   */
  sayNumber: string,
}
models/first-utterance-result.ts
/**
 * 発話結果 インタフェース
 */
export interface IFirstUtteranceResult {
  /**
   * 初回の発話
   */
  speech: string;

  /**
   * 追加の発話
   */
  repromptSpeech: string;
}

これを加えると、FirstUtteranceクラスはこんな感じになります。

utterances/first-utterance.ts
import * as Alexa from 'alexa-sdk';
import { IFirstUtteranceCondition } from '../conditions/first-utterance-condition';
import { IFirstUtteranceResult } from '../models/first-utterance-result';
import { UtteranceBase } from './utterance-base';

/**
 * ファースト発話クラス
 */
export class FirstUtterance extends UtteranceBase {
  /**
   * コンストラクタ
   * @param context Alexaハンドラコンテキスト
   */
  constructor(context: Alexa.Handler<any>) {
    super(context);
  }

  /**
   * 発話内容取得
   * @param condition 条件
   */
  public respond(condition: IFirstUtteranceCondition): IFirstUtteranceResult {
    const result: IFirstUtteranceResult = {
      speech: '',
      repromptSpeech: ''
    };

    return result;
  }
}

あとは、result変数に値をセットするだけですね。

getNewsAsyncメソッドの外部モジュール化

これもTypeScriptを使うので、async, awaitを使った実装に変えていきましょう。
Newsとなってるが、ニュースを返すものではないですが。。。

ニュースリポジトリの作成
$ touch ./models/news-repository.ts
models/news-repository.ts
/**
 * ニュースリポジトリクラス
 */
export class NewsRepository {
  /**
   * コンストラクタ
   */
  constructor() {
    // 処理なし
  }

  /**
   * 内容取得
   * @param sayNumber 数字
   * @returns 内容
   */
  public getAsync(sayNumber: string): Promise<string> {
    // データ(便宜上このクラスに定義しているが、本来はデータベースからエンティティを返す想定)
    let newsContents: {[key: string]: string } = {
      '1': '1番です',
      '2': '2番です',
      '3': '3番です',
    };

    return new Promise((resolve, reject) => {
      if (newsContents[sayNumber]) {
        resolve(newsContents[sayNumber]);
      } else {
        reject();
      }
    });
  }
}

大きく処理は変わっていませんが、内容が取得できなかった場合はrejectを呼び出しています。
では、これを発話クラスに盛り込みましょう。

発話クラスでasync, awaitを使う

utterances/first-utterance.ts
import * as Alexa from 'alexa-sdk';
import { IFirstUtteranceCondition } from '../conditions/first-utterance-condition';
import { IFirstUtteranceResult } from '../models/first-utterance-result';
import { NewsRepository } from '../models/news-repository';
import { UtteranceBase } from './utterance-base';

/**
 * ファースト発話クラス
 */
export class FirstUtterance extends UtteranceBase {
  /**
   * リポジトリ
   */
  private repository: NewsRepository;

  /**
   * コンストラクタ
   * @param context Alexaハンドラコンテキスト
   */
  constructor(context: Alexa.Handler<any>) {
    super(context);

    // リポジトリ作成
    this.repository = new NewsRepository();
  }

  /**
   * 発話内容取得
   * @param condition 条件
   * @returns 結果
   */
  public async respond(condition: IFirstUtteranceCondition): Promise<IFirstUtteranceResult> {
    const result: IFirstUtteranceResult = {
      speech: '',
      repromptSpeech: ''
    };

    // データ取得(非同期)
    const speech = await this.repository.getAsync(condition.sayNumber);

    // 結果をセット
    result.speech = this.alexaContext.t('ASK_ANSWER_NUMBER', speech);
    result.repromptSpeech = this.alexaContext.t('ASK_ANSWER_NUMBER_REPROMPT');

    return result;
  }
}

ここで1つ問題が出ました。。。
this.alexaContext.tの戻り値がvoidになってるんですよね。。。
結果をセットできない。。。
ここの戻り値はstringであるべきだと思ってるんですが、どうなんでしょうか。

types/alexa-sdk

仕方がないので、インタフェースのspeechrepromptSpeechの型をstringからanyにすることで対応しました。
発話クラスはこれで出来上がり!
あとは、ハンドラの呼出部分を変更しましょう。

発話クラスをハンドラから呼ぶ

まずは使う為に、インポートです。

handler/first-handler.ts
import { IFirstUtteranceCondition } from '../conditions/first-utterance-condition';
import { IFirstUtteranceResult } from '../models/first-utterance-result';
import { FirstUtterance } from '../utterances/first-utterance';

次にFirstIntentの呼出部分です。
非同期呼出メソッドでの戻り値の型はPromiseなので、asyncをつけてます。

発話クラスから取得した結果をthis.emitの第2、3引数にセットして終わりです。

handler/first-handler.ts
'FirstIntent': async function (this: Alexa.Handler<any>) {
  var sayNumber = this.event.request.intent.slots.number.value;

  // 発話取得条件設定
  const condition: IFirstUtteranceCondition = {
    sayNumber: sayNumber
  };

  try {
    // 発話内容取得
    const result = await (new FirstUtterance(this)).respond(condition);

    this.emit(':ask', result.speech, result.repromptSpeech);
  } catch (error) {
    this.emitWithState("Unhandled");
  }
},

まとめ

この投稿では、発話に関して外部モジュール化を行いました。

実装編は以上になります。いかがでしたでしょうか?
設計に合わせ分類して実装を行いました。

TypeScriptの使い方、私もまだまだ勉強中ですが、これから始める人の参考になれば幸いです。
細かい説明が無いので分かり辛いかもしれませんが。。。

今回の投稿のソースは以下にあります。
発話外部モジュール化

次回は、いよいよ最後。単体テストです。