Alexaスキル開発を効率化するためのフレームワーク「Talkyjs」を作った背景など

14 min read読了の目安(約13400字

https://talkyjs.dev/

この記事について

Amazon Alexaのカスタムスキルを開発する際に使用するnodejsのライブラリ、「ask-sdk」をより便利に使えるようにするフレームワークを作りました。
そして例によってその紹介記事をまともに書いていないことを思い出したので、ここにまとめます。

背景: スキル開発100チャレンジからask-utilsまで

2018 -> 2019年に「#スキル開発100チャレンジ」というものに挑戦してました。

https://twitter.com/search?q=%23スキル開発100チャレンジ&src=hashtag_click

数をこなすことで、実装や運用の効率化・横展開によるスケールの可能性などを考えることができればと思っての挑戦で、たしか結局日米合わせて70〜80スキルくらいをリリースしました。(質は聞かないでください)

その中で、どのスキルでも使うFunction / Handler / Interceptorなどが出てくるたびに、ask-utilsというライブラリ群の中に追加していました。
よく使う処理系をまとめておいて、また適宜ask-sdkにPull Requestを出したりもして、次のスキルを開発する時はもっと楽になるはずだと思ってチャレンジを続けていた記憶があります。

頓挫: 欲しかったのは便利関数群ではなかった

ただ、60スキルを過ぎたあたりで、そこまで効率化できてないなということに気づきました。
ASK CLIでスキルをセットアップして、npm installでいつも使うライブラリをインストールして、ask-utilsに入っている処理やhandlerなどをSkillBuilderに追加していって・・・

このあたりから、「スキルの中で使うコードの効率化」よりも「スキル開発を始める部分の効率化」したほうが助かるのではという気持ちになっていきます。

ASK-Utils -> Talkyjs

この辺りでたまたまIonic / NestjsなどAngular色の強いライブラリを触れる機会があり、Schematicsの存在を知りました。
nest g ionic startなどで初期環境から必要なファイルまで、コマンド1〜2発で出来上がっていくので、ディレクトリ構造を意識したりtouchcodeコマンドを複数回実行してファイルを都度作るという手間がなく、その便利さに衝撃を受けた記憶があります。

この経験から「もしかしたら、実際に欲しかったのはutilsじゃなくてSchematicsとそれを扱うCLIだったのでは」という考えに至り、CLIコマンドとしてわかりやすい名前を新たにつけようということで生まれたのがTalkyjsです。
ask-utils-cliask-utilsだと少しコマンドとしては長いですし、なによりASK CLIと混同しそうだったことも、新しい名前をつけた理由の1つです。

Talkyjsでできること

ここからは、2020年12月時点でTalkyjsを使ってできることを紹介します。

Skill / Lambda関数向けコードのセットアップ

まずはじめに使うことになるのはセットアップ系コマンドです。

talky initコマンドは、内部でASK CLIを実行してTalkyjsのHello WorldプロジェクトをCloneする形でAlexaスキルのプロジェクトをセットアップします。

% npx @talkyjs/cli init            
npx: 131個のパッケージを6.917秒でインストールしました。
Please follow the wizard to start your Alexa skill project ->
? Choose a method to host your skill's backend resources:  AWS Lambda
  Host your skill code on AWS Lambda (requires AWS account).
[Warn]: CLI is about to download the skill template from unofficial template https://github.com/talkyjs/talkyjs-alexa-skill-template-helloworld.git. Please make sure you understand the source code to best protect yourself from malicious usage.
? Would you like to continue download the skill template?  Yes
? Please type in your skill name:  talkyjs-alexa-skill-template-helloworld
? Please type in your folder name for the skill project (alphanumeric):  talkyjs-alexa-skill-template-hell
oworld
Project for skill "talkyjs-alexa-skill-template-helloworld" is successfully created at /Users/development/talkyjs-alexa-skill-template-helloworld

Project initialized with deploy delegate "@ask-cli/lambda-deployer" successfully.

また、Serverless FrameworkやSAMなどで個別にLambda関数を管理している人向けに、talky setupコマンドを用意しています。
こちらはライブラリのインストールやどのスキルでも必須に近いハンドラー群(LaunchRequest / AMAZON.StopIntent / AMAZON.CancelIntent / AMAZON.HelpIntent)を実行ディレクトリに配置していくコマンドです。

% npx @talkyjs/cli new           
npx: 131個のパッケージを6.634秒でインストールしました。
CREATE .npmignore (202 bytes)
CREATE README.md (719 bytes)
CREATE package.json (1820 bytes)
CREATE tsconfig.json (338 bytes)
CREATE webpack.config.ts (754 bytes)
CREATE src/index.ts (2249 bytes)
CREATE src/tests/index.spec.ts (1406 bytes)
CREATE src/LaunchRequest/LaunchRequest.router.ts (454 bytes)
CREATE src/LaunchRequest/LaunchRequest.speech.tsx (430 bytes)
CREATE src/LaunchRequest/tests/LaunchRequest.router.spec.ts (1226 bytes)
CREATE src/LaunchRequest/tests/LaunchRequest.speech.spec.tsx (503 bytes)
CREATE src/HelpIntent/HelpIntent.router.ts (471 bytes)
CREATE src/HelpIntent/HelpIntent.speech.tsx (427 bytes)
CREATE src/HelpIntent/tests/HelpIntent.router.spec.ts (1219 bytes)
CREATE src/HelpIntent/tests/HelpIntent.speech.spec.tsx (495 bytes)
CREATE src/StopAndCancelAndNoIntent/StopAndCancelAndNoIntent.router.ts (583 bytes)
CREATE src/StopAndCancelAndNoIntent/StopAndCancelAndNoIntent.speech.tsx (274 bytes)
CREATE src/StopAndCancelAndNoIntent/tests/StopAndCancelAndNoIntent.router.spec.ts (1331 bytes)
CREATE src/StopAndCancelAndNoIntent/tests/StopAndCancelAndNoIntent.speech.spec.tsx (607 bytes)
⠦ Installing packages...

この2コマンドを使うことで、スキル開発の1歩目をかなり高速化できます。

DB / SMAPI / ErrorHandler / Loggingの設定

talky newまたはtalky setupでファイルを作成すると、src/index.tsにconfigが用意されています。

const config: TalkyJSSkillConfig = {
    stage: 'development',                   // [Optional] Skill Stage
    logLevel: 'info',                       // [Optional] Log level
    //database: {                             // [Optional] Database configuration
    //    type: "none",                       // [Optional] Database type (none / s3 / dynamodb)
    //    tableName: '',                      // [Optional] Database table name
    //  s3PathPrefix: ''                    // [Optional] [Only S3] S3 path prefix
    //},
    apiClient: {                            // SMAPI and Alexa API Client configuration
        useDefault: true,                   // Use DefaultApiClient
        // client: new DefaultApiClient()   // If you have own ApiClient, put here
    },
    // skillId: '',                         // [Optional] Skill ID
    errorHandler: {                         // [Optional] error handler configurations
        usePreset: true,                    // [Optional] Use preset error handler
    //    sentry: {                         // [Optional] Error tracker configuration (sentry)
    //        dsn: process.env.SENTRY.DSN   // [Optional] Sentry dsn
    //    }
    }
}

/**
 * Skill Factory (Added preset handler)
 */
export const skillFactory = SkillFactory.launch(config)

だいたいコメントしてある通りですが、ASK SDKが利用するPersistenceAdaptorやAPIClientの設定をここでまとめて行うことができます。

またstageproduction以外に設定すると、どのハンドラーにもマッチしなかった場合に「どのインテントとしてハンドルされたか」を返すIntenrReflectorHandlerが自動で追加されます。

比較用として、「S3をPersistenceAdaptorに設定」「DefaultAPIClientでSMAPIを利用」「Sorryメッセージを出すErrorHandlerを設定」という3要件だけのスキルコードを2つ用意しました。

ASK SDKのみの場合

import { CustomSkillFactory, DefaultApiClient } from 'ask-sdk-core';
import { S3PersistenceAdapter } from 'ask-sdk-s3-persistence-adapter';

export const handler = CustomSkillFactory.init()
.withPersistenceAdapter(new S3PersistenceAdapter({
    bucketName: 'name',
    pathPrefix: 'blah'
}))
.withApiClient(new DefaultApiClient())
.addErrorHandlers({
    canHandle() {
        return true
    },
    handle(handlerInput, error) {
        console.log(error)
        return handlerInput.responseBuilder
          .speak('Sorry I could not understand the meaning. Please tell me again')
          .reprompt('Could you tell me onece more?')
          .getResponse();
    }
})
.lambda()

With Talkyjs

import { SkillFactory, TalkyJSSkillConfig } from '@talkyjs/core'
const config: TalkyJSSkillConfig = {
  database: {
      type: "s3",
      tableName: "name",
      s3PathPrefix: 'blah'
  },
  apiClient: {
      useDefault: true,
  },
  errorHandler: {
      usePreset: true,
  }
}

export const handler = SkillFactory.launch(config)
  .getSkill().lambda()

コピーアンドペーストで書くことが多かったコードは、できるだけこのようにTalkyjsの内部で実行するようにつくられています。

また、usePresetがBooleanになっていることからわかるように、明示的にfalseをセットすることで、自前の実装を利用することも可能です。

プリセットハンドラー

AMAZON.RepeatIntent / SessionEndedRequest / AlexaSkillEvent.SkillDisabledの3リクエストに反応するハンドラーがデフォルトで追加されています。

AMAZON.RepeatIntentは直前に話した内容をSessionAttributesに保存して、それをSpeakすることで「もう一度言って」という発話をハンドルします。

SessionEndedRequestは、ただセッションが中断された理由をログ出力するだけのハンドラーです。ですが、このハンドラーをつけ忘れるとスキルレビューで落とされやすいですし、会話を途中で切り上げた時にエラーが起きてしまいます。

AlexaSkillEvent.SkillDisabledのハンドラーは、PersistenceAdaptor経由で保存したユーザーの情報を消す処理を実行します。一度スキルを無効化すると、各種IDが変わる仕様なのであまり意識されない場所ですが、それでもS3やDynamoDBにデータが残っている状態は続きますので、このような形で消せるのが(個人情報保護的にも、お財布的にも)理想かなと考えています。

Router

これは完全にTalkyjs独自実装です。

canHandleを実装する時、Type Guardが必要になったり複数のインテントを拾いたい時に冗長になったりするのが面倒だったので、ある程度の範囲までは宣言的に設定できるようにしました。

  canHandle(handlerInput) {
      const request = getRequest<IntentRequest>(handlerInput.requestEnvelope)
      if (request.type !== 'IntentRequest') return false;
      return ["AMAZON.StopIntent","AMAZON.CancelIntent","AMAZON.NoIntent"].includes(request.intent.name)
  },

これがこうなります。

import { Router } from "@talkyjs/core";
export const StopAndCancelAndNoIntentRouter: Router = {
    requestType: "IntentRequest",
    intentName: ["AMAZON.StopIntent","AMAZON.CancelIntent","AMAZON.NoIntent"],
    handler: async (handlerInput) => {
      return handlerInput.responseBuilder
          .speak("Bye")
          .getResponse()
    }
}

とはいえ細かい条件分岐も必要になるケースはありますので、このようにrequestTypeintentNameの判定をした後にカスタム処理を追加することもできます。

export const HelpIntentForJapaneseRouter: Router = {
    requestType: "IntentRequest",
    intentName: "AMAZON.HelpIntent",
    situation: {
        custom(input) {
            return /ja/.test(input.requestEnvelope.request.locale)
        }
    },
    handler: async (handlerInput) => {
        return handlerInput.responseBuilder
            .speak("The route will execute only in ja locale.")
            .getResponse()
    }
}

ちなみにPersistenceAdaptorを使っているスキル(configでdbをS3かdynamodbに設定している状態)であれば、起動回数による判定もできます。

export const CustomLaunchRequestRouter: Router = {
    requestType: "LaunchRequest",
    situation: {
        invocationCount: {
            gte: 5,
            lte: 10
        }
    },
    handler: async (handlerInput) => {
        return handlerInput.responseBuilder
            .speak("The handle will execute when 5 <= invocation count <= 10")
            .getResponse()
    }
}

JSX Support

これは昨日しれっと変更入れた部分なのですが、TalkyjsではSSMLをJSXでかけます。
一般的な使い方だと、こんな感じで文字列としてSSMLを書いているのではないでしょうか。

handlerInput.responseBuilder
  .speak([
    "<p>Hello! It's a nice development. </p>",
    "<p>You can use a timer after permitted the Timer permission.</p>",
    "<p>Can I use the Timer?</p>",
  ].join(''))
  .reprompt('How are you?')
  .getResponse()

これがJSXだとこうなります。

import React from 'react';
import { SpeechScriptJSX } from '@talkyjs/ssml'

export class InitialLaunchRequestScript extends SpeechScriptJSX {
    speech() {
        return (
            <speak>
                <p>Hello! It's a nice development. </p>
                <p>You can use a timer after permitted the Timer permission.</p>
                <p>Can I use the Timer?</p>
            </speak>
        )
    }
    
    reprompt() {
        return (
            <speak>
                <p>How are you?</p>
            </speak>
        )
    }   
}

以前は内部でssml-tsxを使っていたのですが、公式がリリースしたask-sdk-jsx-for-aplが思いっきりReactだったのでそっちに乗り換えました。

なのでSSMLもAPLもJSXもといReactで書けちゃいます。

Appendix: 単体で使う場合

単体でこれだけ使いたい場合は、@talkyjs/ssmlを使ってもらえればOKです。
ただしJSXを拡張する形でSSMLサポートにしていますので、improt React from 'react'が必須だったりtsconfing.jsonをReact対応にしないといけないなどの手間がありますのでご注意ください。

import React from 'react'
import { renderSSMLToString } from "@talkyjs/ssml";
import { AplDocument } from "ask-sdk-jsx-for-apl";

import { LaunchAplDocumentFC } from "./apl";
import { LaunchRequestSpeech } from "./ssml";

export const LaunchRequestHandler = {
    canHandle(input) {
        return input.requestEnvelope.request.type === 'LaunchRequest'
    },
    async handle(input) {
        return input.responseBuilder
            .speak(
                renderSSMLToString(
                    <LaunchRequestSpeech />
                )
            )
            .addDirective(
                new AplDocument(
                    <LaunchAplDocumentFC />
                ).getDirective()
            )
            .getResponse()
    }
}

そのほか

他にもPersistenceAdaptorの処理系でupdate操作をサポートしたり、default attributesを設定可能にしていたり、「スキルの起動回数」や「セッション内で会話が何回続いているか」や「直近でスキルを起動した日」を記録するInterceptorがあったりといろいろしています。

興味がある方はサイトやGitHubのコードを見てもらって、ついでにProduct HuntのVoteやGitHubのStarをつけてもらえると嬉しいです。

https://talkyjs.dev/docs/get-started/features
https://github.com/talkyjs/talkyjs-core/blob/master/src/CRM/UserActivity.classs.ts#L87-L106

作ってみて

実験的にフレームワークっぽく作ったスキルなどもあるのですが、この形にたどり着くのに半年強かかりました。そして綺麗に燃え尽きた結果、今年は全然スキル開発・リリースやれてないです。

この記事を書く元気が戻ってきたので、多分来年はもうちょっと表向きにもアクティブになるのではと思います。たぶん。きっと。おそらく。

フレームワーク自体は、Alexa Championの伊東さんに触ってもらってレビューしてもらったり、 Ionic Japan User Groupの榊原さんの初スキル開発に使ってもらったりと、ちょっとずつですがユースケースも増えてきています。

https://github.com/le-benaton/alexa-skill
https://hugtech.io/2020/09/13/jaws-sonic-2020-kobe-session/

ドキュメントもそうですが、「実際にどんなスキルができるのか」というところを披露するためにも来年は質にこだわったスキル開発がやれたなと思っているところです。

今後追加するかもしれない機能

  • スキル起動時・終了時のジングル挿入
  • 新機能や広告などの告知テキスト挿入
  • ISP系のユーザーアクティビティの記録
  • Routerの判定条件追加
  • Progressive Responseの自動APIコール(JSX版のみ)
  • Amazon GameOn SDKとのインテグレーション
  • Alexa WebAPIとのインテグレーション
  • アカウントリンク系
  • APL-Aを楽に使える何か(JSX版のみ)

ASK-Utilsについて

2021年以降はTalkyjsの開発や事例などの紹介がメインになると思います。
が、ASK-Utilsの開発が終わるわけではありません。

というのもTalkyjsは「ASK-Utilsをベースにしたフレームワーク」です。そのため、「Talyjsの中にASK-Utilsが利用されている」という形になるだけですので、Talkyjsで必要になった実装がASK-Utilsに実装・リリースされることはもちろんあり得ます。

RouterやSSML JSXなど、ASK-Utilsで公開しているものから大きく仕様が変わる場合のみ、@talkyjs/coreまたは@talyjs/XXXとしてnpmに公開し、必要に応じてASK-Utils版をdeprecatedにします。

Talkyjsという名前について

これは完全に余談ですが、「いいやすく」「コマンド名にもしやすく」「会話に関係しそうなワード」で「ASK系とかぶらない」ものを探した結果これになりました。

「おしゃべりな」という意味を持ちますので、このフレームワークを使って、Alexaスキルがどんどんおしゃべりになってくれならなと思っています。

https://eow.alc.co.jp/search?q=talky

なお、

スマートスピーカー Advent Calendar 2020遅刻記事です。