0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Alexa スキル内課金 第3回 スキル内商品購入後の処理

Last updated at Posted at 2019-10-09

#はじめに
前回はスキル内課金の購入やキャンセルなどの処理の説明をしました。
今回は、スキル内商品購入後の動作について説明します。

#今回実施する内容
スキル内商品を購入した後の動作について説明します。
今回作成しているサンプルスキルは、スキル内商品の中身があるわけではないため、あくまで購入後スキル内商品をどうやって扱うかを記載します。

#環境
OS:Windows 10 JP
Alexaスキル言語:Node.js
Editor:Visual Studio Code
Alexa developer console

#参考
Alexaスキルを使用した商品の販売
Alexzオフィシャルドキュメントの説明です。

AlexaサービスAPIの呼び出し
Node.jsでスキル内課金サービスのAPIへアクセスする説明です。

Alexaデザインガイド スキル内課金
スキル内課金のデザインのベストプラクティスなどの説明です。

ASK SDK for Node.jsのリクエスト処理
Node.jsのリクエスト処理についての説明ですが、リクエストと応答のインターセプターに関する説明が今回の記事内では参照されます。

Alexa Skills Kitのリクエストと応答のインターセプター
Alexa Skills Kitのリクエストのインターセプターに関する説明です。

Alexa スキル内課金 第1回 スキル内課金の仕組みとスキル内商品レコード作成、読み込み
スキル内課金の仕組みの概要の説明です。

Alexa スキル内課金 第2回 スキル内商品の購入処理
スキル内商品の購入・キャンセルなどの説明です。

#用語
##スキル内課金 (ISP)
スキルの中の課金の仕組みこと。
In-skill Purchasing

#前提条件
Alexa スキル内課金 第1回 スキル内課金の仕組みとスキル内商品レコード作成、読み込み
Alexa スキル内課金 第2回 スキル内商品の購入処理
の記事を読んでいる。

#スキル内商品購入後の処理
##スキル内商品購入後の処理の概要
Alexaのスキル内商品は、その名の通り、スキル内の一部が課金商品になります。
スキルそのものが商品ではなく、一部です。
それがプレミアムなサービスという位置づけになっているようです。

当たり前ですが、「スキル内商品を購入したユーザーには、その商品の使用権があり、スキル内商品を購入していないユーザーには、その商品の使用権がありません。」

購入後の処理は、これをしっかり実装する必要があります。
Alexaの仕組みでうまく実装してくれるわけではなく、自分のスキルコード内で、ユーザーが購入済みなのかを確認し、それに従ってスキルを実行します。

でどうやって実現するかと言えば、第1回に説明したスキル内商品レコードなのです。
それをこれから再度説明します。

##スキル内商品レコードの購入前後の違い
スキル内商品レコードは、Node.jsからスキル内課金サービスのMonetizationServiceClient APIを通じてアクセスします。

スキル内商品は、地域(locale)ごとに管理されるため、localeを設定したうえで、情報を取得します。
AlexaサービスAPIの呼び出しに記載されている内容をベースに記載します。

index.jsの一部
const LaunchRequestHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
  },
  handle(handlerInput) {
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();
    return ms.getInSkillProducts(locale).then((result) => {
      console.log(`現在登録済みのスキル内商品: ${JSON.stringify(result.inSkillProducts)}`);
      return handlerInput.responseBuilder
        .speak("スキル内課金へようこそ")
        .withSimpleCard("スキル内課金", "スキル内課金へようこそ")
        .getResponse();
    });
  },
};

上記のソースコードで、購入前と購入後に実行したときのConsoleの出力結果(整形済み)は以下です。

購入前のconsole.logの表示
現在登録済みのスキル内商品: 
[
    {
        "productId":"amzn1.adg.product.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
       "referenceName":"課金商品",
        "type":"ENTITLEMENT",
        "name":"課金商品",
        "summary":"説明です。",
        "entitled":"NOT_ENTITLED",
        "entitlementReason":"NOT_PURCHASED",
        "purchasable":"PURCHASABLE",
        "activeEntitlementCount":0,
        "purchaseMode":"TEST"
    }
]
購入後のconsole.logの表示
現在登録済みのスキル内商品:
[
    {
        "productId": "amzn1.adg.product.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
        "referenceName": "課金商品",
        "type": "ENTITLEMENT",
        "name": "課金商品",
        "summary": "説明です。",
        "entitled": "ENTITLED",
        "entitlementReason": "PURCHASED",
        "purchasable": "NOT_PURCHASABLE",
        "activeEntitlementCount": 1,
        "purchaseMode": "TEST"
    }
]

表に整理すると以下の通りです。差分を強調で表示します。

要素名 購入前 購入後
productId amzn1.adg.product.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX amzn1.adg.product.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
referenceName 課金商品 課金商品
type ENTITLEMENT ENTITLEMENT
name 課金商品 課金商品
summary 説明です。 説明です。
entitled NOT_ENTITLED ENTITLED
entitlementReason NOT_PURCHASED PURCHASED
purchasable PURCHASABLE NOT_PURCHASABLE
activeEntitlementCount 0 1
purchaseMode TEST TEST

上記の通り、購入後に変更されるのは、entitledentitlementReasonpurchasableactiveEntitlementCountの4つである。
購入済みかどうか判断するためには、entitledを使うのがよいかと思います。
これとスキル内商品が複数ありうる可能性を加味して、referenceNameで識別すればよいかと思います。
消費型の場合は、消費した回数をカウントする必要がありそうですが、今回は説明を割愛します。
スキル内課金のドキュメントによれば、activeEntitlementCountは購入回数を示すだけであり、消費回数は別途開発者が管理しなければならないようです。

##スキル内商品レコードのセッションアトリビュートへの追加
スキルの内容にも依存しますが、
「スキル内商品を購入したユーザーには、その商品の使用権があり、スキル内商品を購入していないユーザーには、その商品の使用権がありません。」
を実現するために、多くのIntentのHandlerに上記のようなスキル内商品レコードを取得する処理が必要になることが想定されます。

Amazon JapanのAlexa道場においては、リクエストのインターセプターを使用して、スキル内商品レコードのうち、資格のある商品をハンドラーのセッションアトリビュートに付与する実装がソースに記載されていましたので、紹介します。
Alexa道場のサンプルソースコードで、リクエストのインターセプターを初めて知りました。なかなかすべてのドキュメントに目を通すのは難しいため、Alexa道場ででてくるようなソースコードはタメになります。

実施方法は簡単で、

  1. スキル内商品レコードの取得
  2. リクエストのインターセプターを使って、ハンドラーにセッションアトリビュートを設定

だけです。

リクエストのインターセプターは、ASK SDK for Node.jsのリクエスト処理に説明がありますが、抜粋します。

SDKは、RequestHandlerの実行前と実行後に実行するリクエストと応答のインターセプターをサポートします。インターセプターは、RequestInterceptorインターフェースかResponseInterceptorインターフェースを使用して実装できます。
どちらのインターセプターインターフェースも、戻り値の型がvoidであるprocessメソッドを1つ実行します。リクエストのインターセプターはHandlerInputオブジェクトにアクセスでき、応答のインターセプターはHandlerInputと、RequestHandlerによって生成されるオプションのResponseにアクセスできます。

interface RequestInterceptor {
process(handlerInput: HandlerInput): Promise | void;
}


> リクエストのインターセプターは、受け取るリクエストのリクエストハンドラーが実行される直前に呼び出されます。リクエストアトリビュートは、リクエストのインターセプターがリクエストハンドラーにデータやエンティティを渡す方法を提供します。
>
>以下の例は、SDKを使ってインターセプターを登録する方法を示しています。

>```js
const Alexa = require('ask-sdk-core');
>
const skill = Alexa.SkillBuilders.custom()
  .addRequestHandlers(
    FooHandler,
    BarHandler,
    BazHandler)
  .addRequestInterceptors(
    FooRequestInterceptor,
    BarRequestInterceptor)
  .addResponseInterceptors(
    FooResponseInterceptor,
    BarResponseInterceptor)
  .create();

上記に基づいたコードを示します。

/**
 * 購入済みスキル内商品のobjectを取得・応答する。
 * 
 * @param {object} inSkillProductList スキル内商品レコードのオブジェクト。
 * @return {object} 購入済みスキル内商品のオブジェクト。
 */
const getAllEntitledProducts = (inSkillProductList) => {
  const entitledProductList = inSkillProductList.filter(record => record.entitled === 'ENTITLED');
  console.log(`Currently entitled products: ${JSON.stringify(entitledProductList)}`);
  return entitledProductList;
};

/**
 * Request Interceptor(addResponseInterceptors)により、すべてのリクエストハンドラーが呼び出される直前に処理するための関数。
 * どの商品をすでに購入済みかを確認し、セッションアトリビュート(attributes)に付加する。
 * 購入済みのスキル内商品は、attributes.entitleProductsに配列で付加される。
 */
const addEntitleProductsToAttributes = {
  async process(handlerInput) {
    if (handlerInput.requestEnvelope.session.new === true) {
      try {
        const locale = handlerInput.requestEnvelope.request.locale;
        const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();
        const result = await ms.getInSkillProducts(locale);
        const entitledProducts = getAllEntitledProducts(result.inSkillProducts);
        const attributes = handlerInput.attributesManager.getSessionAttributes();
        attributes.entitledProducts = entitledProducts;
        handlerInput.attributesManager.setSessionAttributes(attributes);
      } catch (error) {
        console.log(`Error calling InSkillProducts API: ${error} `);
      }
    }
  },
};
const skillBuilder = Alexa.SkillBuilders.standard();
 
exports.handler = skillBuilder
  .addRequestHandlers(
    LaunchRequestHandler,
    BuyISPReqIntentHandler,
    BuyUpsellResponseHandler,
    RecommendISPReqIntentHandler,
    CancelISPReqIntentHandler,
    CancelResponseHandler,
    HelpIntentHandler,
    CancelAndStopIntentHandler,
    SessionEndedRequestHandler
  )
  .addRequestInterceptors(addEntitleProductsToAttributes)
  .addErrorHandlers(ErrorHandler)
  .lambda();

###getAllEntitledProducts

種類 引数名 説明
引数 inSkillProductList object スキル内商品レコードのオブジェクト。
戻り値 なし オブジェクト 購入済みスキル内商品のオブジェクト。

処理は、inSkillProductsの中で、entitledの値がENTITLEDとなっている資格のある(購入済みの)商品だけをフィルターして、その商品の応答を返すというものです。

###addEntitleProductsToAttributes

これは、RequestInterceptorインターフェイスを実装する関数です。
promiseで戻り値はなしです。
処理は、handlerInputでセッションがnewとなっている場合、スキル内商品レコードを取得して、その値をセッションアトリビュートに設定するというもので、具体的には、attributes.entitledProductsを設定します。
セッションがnewになるタイミングは、

  • スキル起動時
  • スキル内商品購入処理を実施して、AlexaからConnextions.Responseをもらうタイミング

が想定されます。

###addRequestInterceptors
skillBuilderに.addRequestInterceptorsをつけて、その引数にaddEntitleProductsToAttributesをつけるだけで、ハンドラーのセッションアトリビュートに購入済みのスキル内商品レコードを貼り付けることができます。
おもに、スキルの起動時がセッションがnewとなるため、そのときに設定されるということです。

###セッションアトリビュートの確認
それでは、上記で作成したソースでセッションアトリビュートが設定されるか確認します。

まずは、スキル内商品が未購入状態で、スキルを起動(LaunchRequest)して、その応答がどうなるかです。

"sessionAttributes": {
    "entitledProducts": []
},

続いてこのままこれを購入(BuyISPReqIntent)すると、その応答はやはり、entitledProductsはありません。

"sessionAttributes": {
    "entitledProducts": []
},

スキルを購入すると、その応答に、entitledProductsが追加されます。

"sessionAttributes": {
    "entitledProducts": [
        {
            "productId": "amzn1.adg.product.XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
            "referenceName": "課金商品",
            "type": "ENTITLEMENT",
            "name": "課金商品",
            "summary": "説明です。",
            "entitled": "ENTITLED",
            "entitlementReason": "PURCHASED",
            "purchasable": "NOT_PURCHASABLE",
            "activeEntitlementCount": 1,
            "purchaseMode": "TEST"
        }
    ]
},

こうなればOKで、スキルを終了して再度スキルを起動(LaunchRequest)しても、購入済みのスキル内商品レコードは、セッションアトリビュートに設定されます。

このセッションアトリビュートの値を使って、ENTITLEDの場合、スキル内商品を提供すればよいのです。

##ソースコード
第1回から第3回までで作成したソースコードを載せます。

const Alexa = require('ask-sdk');

/**
 * 購入済みスキル内商品のobjectを取得・応答する。
 * 
 * @param {object} inSkillProductList スキル内商品レコードのオブジェクト。
 * @return {object} 購入済みスキル内商品のオブジェクト。
 */
const getAllEntitledProducts = (inSkillProductList) => {
  const entitledProductList = inSkillProductList.filter(record => record.entitled === 'ENTITLED');
  console.log(`Currently entitled products: ${JSON.stringify(entitledProductList)}`);
  return entitledProductList;
};

/**
 * Request Interceptor(addResponseInterceptors)により、すべてのリクエストハンドラーが呼び出される直前に処理するための関数。
 * どの商品をすでに購入済みかを確認し、セッションアトリビュート(attributes)に付加する。
 * 購入済みのスキル内商品は、attributes.entitleProductsに配列で付加される。
 */
const addEntitleProductsToAttributes = {
  async process(handlerInput) {
    if (handlerInput.requestEnvelope.session.new === true) {
      try {
        const locale = handlerInput.requestEnvelope.request.locale;
        const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();
        const result = await ms.getInSkillProducts(locale);
        const entitledProducts = getAllEntitledProducts(result.inSkillProducts);
        const attributes = handlerInput.attributesManager.getSessionAttributes();
        attributes.entitledProducts = entitledProducts;
        handlerInput.attributesManager.setSessionAttributes(attributes);
      } catch (error) {
        console.log(`Error calling InSkillProducts API: ${error} `);
      }
    }
  },
};

const LaunchRequestHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
  },
  handle(handlerInput) {
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();
    return ms.getInSkillProducts(locale).then((result) => {
      console.log(`現在登録済みのスキル内商品: ${JSON.stringify(result.inSkillProducts)}`);
      const speechOutput = "スキル内課金へようこそ。課金商品を購入しますか?レコメンドしますか?キャンセルしますか?";
      return handlerInput.responseBuilder
        .speak(speechOutput)
        .reprompt(speechOutput)
        .withSimpleCard("スキル内課金", "スキル内課金へようこそ")
        .getResponse();
    });
  },
};
 
const BuyISPReqIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
       &&  handlerInput.requestEnvelope.request.intent.name === 'BuyISPReqIntent';
  },
  handle(handlerInput) {
    console.log("BuyISPReqIntentHandler");
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();
    return ms.getInSkillProducts(locale).then((result) => {
      // 購入する商品データを抽出
      const product = result.inSkillProducts.filter(record => record.referenceName === "課金商品");
      // ** ディレクティブにBuyを送信し購入フローに進む ** 
      return handlerInput.responseBuilder
        .addDirective({
          type: 'Connections.SendRequest',
          name: 'Buy',
          payload: {
            InSkillProduct: {
              productId: product[0].productId,
            },
          },
          token: 'token',
        })
        .getResponse();
    });
  },
};

const BuyUpsellResponseHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'Connections.Response'
        && (handlerInput.requestEnvelope.request.name === 'Buy'
        || handlerInput.requestEnvelope.request.name === 'Upsell');
  },
  handle(handlerInput) {
    console.log(`BuyUpsellResponseHandler`);
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();

    return ms.getInSkillProducts(locale).then(() => {
      if (handlerInput.requestEnvelope.request.status.code === '200') {
        let speakOutput;
        let repromptOutput;
        switch (handlerInput.requestEnvelope.request.payload.purchaseResult) {
          case 'ACCEPTED':
            // 購入した
            speakOutput = `購入しました。`;
            repromptOutput = `購入しました。`;
            break;
          case 'DECLINED':
            // 購入しなかった
            if (handlerInput.requestEnvelope.request.name === 'Buy') {
              speakOutput = `また購入検討してくださいね。`;
              repromptOutput = `また購入検討してくださいね。`;
              break;
            }  
            speakOutput = `またレコメンドしますね。`;
            repromptOutput = `またレコメンドしますね。`;            
            break;
          case 'ALREADY_PURCHASED':
            // 購入済みだった
            speakOutput = `購入済みでした。`;
            repromptOutput = `購入済みでした。`;
            break;
          default:
            // 何らかの理由で購入に失敗した場合。
            speakOutput = `購入できませんでした。音声ショッピングの設定やお支払い方法をご確認ください。`;
            repromptOutput = `購入できませんでした。音声ショッピングの設定やお支払い方法をご確認ください。`;
            break;
        }
        return handlerInput.responseBuilder
          .speak(speakOutput)
          .reprompt(repromptOutput)
          .getResponse();
      }
      // 処理中にエラーが発生した場合
      console.log(`Connections.Response indicated failure.error: ${handlerInput.requestEnvelope.request.status.message} `);

      return handlerInput.responseBuilder
        .speak('購入処理でエラーが発生しました。もう一度試すか、カスタマーサービスにご連絡ください。')
        .getResponse();
    });
  },
};

const RecommendISPReqIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
       &&  handlerInput.requestEnvelope.request.intent.name === 'RecommendISPReqIntent';
  },
  handle(handlerInput) {
    console.log("RecommendISPReqIntentHandler");
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();
    return ms.getInSkillProducts(locale).then((result) => {
      // 購入する商品データを抽出
      const product = result.inSkillProducts.filter(record => record.referenceName === "課金商品");
      // ** ディレクティブにUpsellを送信し購入フローに進む ** 
      return handlerInput.responseBuilder
        .addDirective({
          type: 'Connections.SendRequest',
          name: 'Upsell',
          payload: {
            InSkillProduct: {
              productId: product[0].productId,
            },
            upsellMessage: product[0].summary
          },
          token: 'token',
        })
        .getResponse();
    });
  },
};

const CancelISPReqIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
      && handlerInput.requestEnvelope.request.intent.name === 'CancelISPReqIntent';
  },
  handle(handlerInput) {
    console.log(`CancelISPReqIntentHandler`);
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();

    return ms.getInSkillProducts(locale).then(function initiateCancel(result) {
      // 購入する商品データを抽出
      const product = result.inSkillProducts.filter(record => record.referenceName === "課金商品");

      // Cancelディレクティブを送信
      return handlerInput.responseBuilder
        .addDirective({
          type: 'Connections.SendRequest',
          name: 'Cancel',
          payload: {
            InSkillProduct: {
              productId: product[0].productId,
            },
          },
          token: 'token',
        })
        .getResponse();
    });
  },
};

const CancelResponseHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'Connections.Response'
        && handlerInput.requestEnvelope.request.name === 'Cancel';
  },
  handle(handlerInput) {
    console.log(`CancelResponseHandler`);
    const speechText = "キャンセル";
    return handlerInput.responseBuilder
      .speak(speechText)
      .reprompt(speechText)
      .withSimpleCard("スキル内課金", speechText)
      .getResponse();
  },
};

const HelpIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
      && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent';
  },
  handle(handlerInput) {
    const speechText = 'You can say hello to me!';
 
    return handlerInput.responseBuilder
      .speak(speechText)
      .reprompt(speechText)
      .withSimpleCard('Hello World', speechText)
      .getResponse();
  },
};
 
const CancelAndStopIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
      && (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent'
        || handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent');
  },
  handle(handlerInput) {
    const speechText = 'Goodbye!';
 
    return handlerInput.responseBuilder
      .speak(speechText)
      .withSimpleCard('Hello World', speechText)
      .getResponse();
  },
};
 
const SessionEndedRequestHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest';
  },
  handle(handlerInput) {
    console.log(`Session ended with reason: ${handlerInput.requestEnvelope.request.reason}`);
 
    return handlerInput.responseBuilder.getResponse();
  },
};
 
const ErrorHandler = {
  canHandle() {
    return true;
  },
  handle(handlerInput, error) {
    console.log(`Error handled: ${error.message}`);
 
    return handlerInput.responseBuilder
      .speak('エラーが発生しました')
      .reprompt('エラーが発生しました')
      .getResponse();
  },
};
 
const skillBuilder = Alexa.SkillBuilders.standard();
 
exports.handler = skillBuilder
  .addRequestHandlers(
    LaunchRequestHandler,
    BuyISPReqIntentHandler,
    BuyUpsellResponseHandler,
    RecommendISPReqIntentHandler,
    CancelISPReqIntentHandler,
    CancelResponseHandler,
    HelpIntentHandler,
    CancelAndStopIntentHandler,
    SessionEndedRequestHandler
  )
  .addRequestInterceptors(addEntitleProductsToAttributes)
  .addErrorHandlers(ErrorHandler)
  .lambda();

#おわりに

今回は、主に以下について説明しました。

  • 購入前後で変化するスキル内商品レコード
  • スキル内商品レコードの取得方法
  • スキル内商品レコードのセッションアトリビュートへの追加
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?