するめごはんのIT日記

主にITネタを書いていくのさ

深夜テンションで創ってAmazonさんの審査員さんに協議させてしまった「ノリノリのサンタ」の話

ごきげんよう

この記事は ADVENTARの「するめごはんのVUI・スマートスピーカー Advent Calendar 2018」 の21日目の記事です。

Echo Showのために4つのスキルを作成した方々、お疲れ様です。

さて、今回は僕が完全に深夜テンションで作成したAlexaスキル「ノリノリのサンタ」について記載します。

相変わらず、AmazonJapanの方を大至急協議させてしまいましたので、それについても触れていきます。

Alexaスキル「ノリノリのサンタ」とは

f:id:surumegohan:20181221221530p:plain
ノリノリのサンタ

「なんかクリスマスのスキルを創るかー」
と思って、いらすとや ではない素材を探していたら、テンションが高そうなサンタクロースの画像素材を見つけました。

なので、サンタクロースとかクリスマスとかについての雑学をググって、まとめて、僕が深夜テンションで自分で声を収録しました

もちろん画面対応

Echo Showが来る前にリリースしたので、Echo Spotでの画面表示に対応しています。

テンション高めなサンタクロースの画像と、ノリノリな声がぞれぞれランダムに再生されます。

ソースコードの一部

めっちゃ一部ですが、たいしたことはしてないです。
ユーザーが初回起動か否かの判定と、操作説明をオーディオファイルで流しているだけです。
画像とオーディオファイルは別々にランダムで表示させています。

'use strict';

const Alexa = require('ask-sdk');
const AWS = require("aws-sdk");
const docClient = new AWS.DynamoDB.DocumentClient({region: 'ap-northeast-1'});


//音声を定義

//起動時
const smartmacchiato = '<audio src=\"https://s3XXXXXXX.mp3\" />';


//2回目以降、わしがのりのりサンタじゃあ
const washiganorinori = '<audio src=\"https://s3-XXXXXX.mp3\" />';

//初回ユーザー用説明
const first_user = '<audio src=\"https://s3-XXXXXXXXXXX.mp3\" />';

:
:
:

//トリビアの数
const norinori_01 = '<audio src=\"https://s3-XXXXXXXXXXX/norinori1.mp3\" />';
const norinori_02 = '<audio src=\"https://s3-XXXXXXXXXXX/norinori2.mp3\" />';
const norinori_03 = '<audio src=\"https://s3-XXXXXXXXXXX/norinori3.mp3\" />';

:
:
:

//トリビアのの音声配列
var norinori_speak_array = [
    norinori_01,
    norinori_02,
    norinori_03,
        :
        :
        :
];



//画像
const DisplayImg1 = {
      title: 'のりのりサンタ1',
      url: 'https://s3-XXXXXXXXXXXX/img/santa1.png'
    };
    
const DisplayImg2 = {
      title: 'のりのりサンタ2',
      url: 'https://s3-XXXXXXXXXXXX/img/santa2.png'
};

const DisplayImg3 = {
      title: 'のりのりサンタ3',
      url: 'https://s3-XXXXXXXXXXXX/img/santa3.png'
};

:
:
:


//サンタ画像の配列
var norinori_img_array = [
    DisplayImg1,
    DisplayImg2,
    DisplayImg3,
        :
        :
];


//////////////////////////////////////////////////////////////////////////


const LaunchRequestHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
  },
  async handle(handlerInput) {


    //サンタ話のうち1つをランダムで選ぶ
    var factSpeakArr = norinori_speak_array;
    var factSpearkIndex = Math.floor(Math.random() * factSpeakArr.length);
    var randomSpearkFact = factSpeakArr[factSpearkIndex];


    //サンタ画像のうち1つをランダムで選ぶ
    var factImgArr = norinori_img_array;
    var factImgIndex = Math.floor(Math.random() * factImgArr.length);
    var randomImgFact = factImgArr[factImgIndex];


    // Template 6
    if (supportsDisplay(handlerInput)){
      const myImage1 = new Alexa.ImageHelper()
        .addImageInstance(randomImgFact.url)
        .getImage();

      const myImage2 = new Alexa.ImageHelper()
        .addImageInstance(randomImgFact.url)
        .getImage();

      const primaryText = new Alexa.RichTextContentHelper()
        .withPrimaryText('')
        .getTextContent();

        handlerInput.responseBuilder.addRenderTemplateDirective({
        type: 'BodyTemplate6',
        token: 'string',
        backButton: 'HIDDEN',
        backgroundImage: myImage2,
        image: myImage1,
        title: "",
        textContent: primaryText
      });
    }
     


    //JSONを扱う関連      
    let handlerInput_json = await JSON.stringify(handlerInput, null, 2);
          
    (略)

    let norinori_start = washiganorinori + randomSpearkFact;

    try{
        
        const queryItems = await docClient.query({
          TableName: "norinoriSantaTable", 
          KeyConditionExpression: "#userId = :userId",
          ExpressionAttributeNames: {"#userId": "userId"},
          ExpressionAttributeValues: {":userId": JSONのユーザーID}
        }).promise();
        
        try{
            
            console.log("queryItems.Items[0].userId: " + queryItems.Items[0].userId);
        
        } catch (err){ //よろしくない実装
            
            //初回ユーザー用のオーディオファイルにする
            norinori_start = first_user;
            
            console.log("user Nothing");
        }
        
    } catch(err){
        console.error(`[query Error]: ${JSON.stringify(err)}`);
    }
    

    //DynamoDBにputする情報
    var item = {
           userId: JSONからのユーザーID,
    };

    var params = {
        TableName: 'テーブル名',
        Item: item
    };

    //DynamoDBにPut
    await putDynamo(params);

    //sessionAttributeを、起動後であることを示すように格納
    var sessionAttribute = '';
    
        sessionAttribute = {
        "SHA_state": "after_start"
        };
    
    handlerInput.attributesManager.setSessionAttributes(sessionAttribute); 

    //しゃべる音声スキルと、間に0.7秒の待機を挟み、ノリノリの話とユーザーへの操作説明をする
    let speechText = smartmacchiato + '<break time="0.7s"/>' + norinori_start + ask_next;

    return handlerInput.responseBuilder
      .speak('<speak>' + speechText + '</speak>')
      .reprompt('<speak>' + speechText + '</speak>')
      .withShouldEndSession(false)
      .getResponse();
  }
};

    

//起動直後、本アプリの継続に「はい」「次」「もっと」「のりのり」「きかせて」と答えた場合の処理
const continueHandler = {
    canHandle(handlerInput) {
      
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && ((handlerInput.requestEnvelope.request.intent.name === 'AMAZON.YesIntent')
                || (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.NextIntent')
                || (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.MoreIntent')
                || (handlerInput.requestEnvelope.request.intent.name === 'norinoriIntent')
                || (handlerInput.requestEnvelope.request.intent.name === 'kikitaiIntent')
                )
            && handlerInput.attributesManager.getSessionAttributes().SHA_state == 'after_start';
    },
    async handle(handlerInput,event) {


    //サンタ話のうち1つをランダムで選ぶ
    var factSpeakArr = norinori_speak_array;
    var factSpearkIndex = Math.floor(Math.random() * factSpeakArr.length);
    var randomSpearkFact = factSpeakArr[factSpearkIndex];


    //サンタ画像のうち1つをランダムで選ぶ
    var factImgArr = norinori_img_array;
    var factImgIndex = Math.floor(Math.random() * factImgArr.length);
    var randomImgFact = factImgArr[factImgIndex];
    
    
    // Template 6
    if (supportsDisplay(handlerInput)){
      const myImage1 = new Alexa.ImageHelper()
        .addImageInstance(randomImgFact.url)
        .getImage();

      const myImage2 = new Alexa.ImageHelper()
        .addImageInstance(randomImgFact.url)
        .getImage();

      const primaryText = new Alexa.RichTextContentHelper()
        .withPrimaryText('')
        .getTextContent();

        handlerInput.responseBuilder.addRenderTemplateDirective({
        type: 'BodyTemplate6',
        token: 'string',
        backButton: 'HIDDEN',
        backgroundImage: myImage2,
        image: myImage1,
        title: "",
        textContent: primaryText
      });
    }
    
        //ランダムに話して、ユーザー操作を促す
        const speechText = randomSpearkFact + ask_next;
        
        return handlerInput.responseBuilder
              .speak('<speak>' + speechText + '</speak>')
              .reprompt('<speak>' + speechText + '</speak>')
              .withShouldEndSession(false)
              .getResponse();
    
    }
};




//DynamoDBにputする関数
function putDynamo(params) {
    
    console.log("=== putDynamo function ===" + params);
    
    docClient.put(params, function (err, data) {
    
        console.log("=== put ===");
    
        if (err) {
            console.log(err);
        } else {
            console.log(data);
        }
    });

}


// returns true if the skill is running on a device with a display (show|spot)
function supportsDisplay(handlerInput) {
  var hasDisplay =
    handlerInput.requestEnvelope.context &&
    handlerInput.requestEnvelope.context.System &&
    handlerInput.requestEnvelope.context.System.device &&
    handlerInput.requestEnvelope.context.System.device.supportedInterfaces &&
    handlerInput.requestEnvelope.context.System.device.supportedInterfaces.Display;

  console.log("Supported Interfaces are" + JSON.stringify(handlerInput.requestEnvelope.context.System.device.supportedInterfaces));
  return hasDisplay;
}

リジェクト内容で協議させてしまうパターン

僕にとっては、もう慣れているので構わないのですが、リジェクト理由に納得がいかずに、進言したら協議の上、承認されました。

リジェクト理由は 「ノリノリ」は名詞ではないから。

日本語でスキルの呼び出し名を決める際には、原則として名詞を2つにする必要があります。

ヒロイン告白」みたいな。 「の」は無くてもOKの場合も多いです。

で、納得いかないので以下のように伝えたら承認されました。

1.「ノリノリ」は名詞でも使われる

goo辞書で検索すると、以下のように記載されてます。

[名・形動]《動詞「乗る」の連用形を重ねた語。「ノリノリ」と書くことも多い》調子がよくて気分が高揚していること。乗りがよくて、リズミカルであること。また、そのさま。いけいけ。「乗り乗りな曲で踊る」「乗り乗りムードで一気に勝ち進む」

[名・形動]ってあるじゃん。

2.テンションが高い場合はどう表現するのか

テンションが高い状態を示す場合、具体的にAmazonさんはどういう表現なら良いのか求めました。

そしてその際に、

仮に「ハイテンションなサンタ」にした場合、【ハイテンション】こそ日本語ではないと私は解釈します。 日本語のみで状況・状態を説明するための具体的なガイドラインをください。

と、伝えました。

そしたら通った

いつも通り?、「大至急協議します」のメールが飛んでくるので、しばし待ちます。 Amazonさんから「大至急協議します」のメールが来た場合、たいていその日のうちに返答がきます。

今回は 「協議の上、認められることになりましたので、再申請をお願いします。」

とのことで、再申請したら通りました。

その後のAlexaDevSummitでつかまる

AlexaDevSummitで会場をウロウロしてたら、中の人からお声がけがありました。

A「showさんですよね!この間の件ですが・・・」
僕「(やらかしまくっているので)どの件でしょうかごめんなさi・・・」
A「ノリノリの件です。ご指摘ありがとうございました。あれはたしかに認められないと表現できないですよね。フィードバックありがとうございました!」
僕「こちらこそ、ご対応ありがとうございましたぁぁぁ!!!」

全力で土下座する体制にしようとしましたが、むしろ感謝されました。

何が言いたいかというと、最近、スキル審査結果に対してフィードバックが画面ポチポチで選べるようになったんですけども、審査員側だって、ユーザーからのご意見が欲しいわけです。

明らかに開発者側のミスは置いておいて、リジェクトされたから黙って従うだと、AmazonさんのAlexaスキル審査員さんが独りよがりの神になってしまうわけです。

なので、ちゃんと根拠を示して、自分の意見を伝えると、AmazonのAlexa担当の方々はちゃんと対応してくれますよ。

リジェクトを頂いても、認定されても、フィードバックは送りましょう。 その方がお互いにハッピーだと思います。

以上。