深夜テンションで創ってAmazonさんの審査員さんも動かしてしまった「ノリノリのサンタ」の話

ごきげんよう

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

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

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

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

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

![nori_R108.png](https://qiita-image-store.s3.amazonaws.com/0/67398/53b0a288-bb22-0639-d529-05d8b4854ddc.png)


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

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

#もちろん画面対応

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

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

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


```javascript:ノリノリのサンタ

'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つにする必要があります。

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

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

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

goo辞書で検索すると、以下のように記載されてます。
>［名・形動］《動詞「乗る」の連用形を重ねた語。「ノリノリ」と書くことも多い》調子がよくて気分が高揚していること。乗りがよくて、リズミカルであること。また、そのさま。いけいけ。「乗り乗りな曲で踊る」「乗り乗りムードで一気に勝ち進む」


<b><span style="font-size: 150%">［名・形動］ってあるじゃん。</span></b>

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

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

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

と、伝えました。

##そしたら通った

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

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

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

#その後のAlexaDevSummitでつかまる

AlexaDevSummitで会場をウロウロしてたら、中の人からお声がけがありました。
  
<b><span style="color: #ff0000">A「showさんですよね！この間の件ですが・・・」</span></b>  
<span style="color: #0000cc"><b>僕「（やらかしまくっているので）どの件でしょうかごめんなさi・・・」</b></span>  
<span style="color: #ff0000"><b>A「ノリノリの件です。ご指摘ありがとうございました。あれはたしかに認められないと表現できないですよね。フィードバックありがとうございました！」 </b></span>   
<span style="color: #0000cc"><b>僕「こちらこそ、ご対応ありがとうございましたぁぁぁ！！！」</b></span>  
  
全力で土下座する体制にしようとしましたが、むしろ感謝されました。  
  
何が言いたいかというと、最近、スキル審査結果に対してフィードバックが画面ポチポチで選べるようになったんですけども、審査員側だって、ユーザーからのご意見が欲しいわけです。

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

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

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


以上。