🤖

Alexaにスプラ3のスケジュールを教えてもらいたい

2022/10/19に公開

はじめに

目標

  • わたし「アレクサ、バンカラマッチのスケジュール教えて」
    Alexa「〇〇時からのバンカラチャレンジはガチ□□で、ステージは++++と****です」
    がやりたい!

公式のアプリは他のゲームの機能と一体のせいかやたらと開くの時時間がかかり、「ちょっとゲームしようかなー、いまルール何かなー」と開くと「………………………………」となるので気軽に開けない
また、公式の提供しているウィジェットもステージは見れるのになぜかルールが見れないと意味が分からない状況(10/19追記:見られるようになっていました。バグだったのかな)
スプラ2時代はikaWidget 2を愛用させてもらっていたが、3はまだ出ていないよう(10/19追記:出たみたいですので皆さんはぜひこちらを

というわけで自分で作るしかない!どうせ作るならアレクサにしゃべらせるのが便利だ!と思いっ立ったのですが、慣れないプラットフォームに苦戦しているうちに公式ウィジェットもikaWidget 3も出てしまい執筆時点で作る意味はなくなってしまったのですが、せっかく作ったので備忘録がてら書き残しておきます

方法

使わせていただくAPIはこちら
https://spla3.yuu26.com/
個人でやられているもののようですので、この記事を真似してやってみる方も良識の範囲内で利用させてもらいましょう

アレクサの実装で参考にしたのはこちらの記事です
https://qiita.com/hellscare/items/ff09b2f171be1588ce7b

SplBattleScheduleIntentというインテントを作成し、battleType, whenという二つのインテントスロットを追加します

スロットのタイプSplThreeBattleType_LISTはこんな感じで設定しています。SplThreeScheduleType_LISTの方も似たような感じで、自分が発話したいワードを登録すればOK

「スキルの呼び出し名」や「スロットのタイプ」はほかの単語に聞き間違えられないユニークな文字列の方がよさそうです
たとえば私は最初、スキルの呼び出し名にイカという単語を入れていたのですがどうもこれが「以下」などほかの単語に聞き間違えられてしまうようで、コンソールでのテストでは上手くいくのに実機でのテストだと明日の予定なんかを読み上げられてしまったり上手くいきませんでした

ソースコード

jsもnodeも超初心者なので荒いところがあるかもしれませんが
IntentReflectorHandlerの部分を以下の様に書き換えます

// 無理やり待つ用の自作Sleep関数
async function sleep(millis) {
    return new Promise(resolve => setTimeout(resolve, millis));
}
/* *
 * The intent reflector is used for interaction model testing and debugging.
 * It will simply repeat the intent the user said. You can create custom handlers for your intents 
 * by defining them above, then also adding them to the request handler chain below 
 * */
const IntentReflectorHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest';
    },
    async handle(handlerInput) {
        let strResponse = 'Response';
        const intentName = Alexa.getIntentName(handlerInput.requestEnvelope);
        
	// 呼ばれたのが自分が設定したSplBattleScheduleIntentかチェック
        if(intentName === 'SplBattleScheduleIntent'){
            const {
              getSlot
            } = require('ask-sdk-core');
            
	    // 発話されたワード(スロット)を取得
            const BATTLE_TYPE = 'battleType';
            const WHEN_TYPE = 'when';                        
            const bType = getSlot(handlerInput.requestEnvelope, BATTLE_TYPE)
            const whenType = getSlot(handlerInput.requestEnvelope, WHEN_TYPE)
            
            var bTypeStr = 'error';
            var wTypeStr = 'error';
            try{
		// モードのスロットの値を取得
                const battleId = bType.resolutions.resolutionsPerAuthority[0].values[0].value.name;
		// 特に処理しないからSwitch要らないけど
                switch(battleId){
                    case "regular":
                        bTypeStr = "regular";
                        break;
                    case "bankara-challenge":
                        bTypeStr = "bankara-challenge";
                        break;
                    case "bankara-open":
                        bTypeStr = "bankara-open";
                        break;
                    case "fest":
                        bTypeStr = "fest";
                        break;
                    case "coop-grouping-regular	":
                        bTypeStr = "coop-grouping-regular";
                        break;
                    default:
                        bTypeStr = "default";
                        break;
                }
		// いつのスロットの値を取得
		// いつの方は発話されない場合を許容するのでチェック
                if(whenType.resolutions === undefined){
		    // この場合はすべてのスケジュールを取得することにする
                    wTypeStr = "schedule";
                }else{
                    const whenId = whenType.resolutions.resolutionsPerAuthority[0].values[0].value.name;
		    // こっちも特にほかの処理しないからSwitch要らないけど
                    switch(whenId){
                        case "now":
                            wTypeStr = "now";
                            break;
                        case "next":
                            wTypeStr = "next";
                            break;
                        default:
                            wTypeStr = "default";
                            break;
                    }
                }
            }catch(e){
                throw(e);
            }
            // 想定していない値が来た場合defaultになっているのでチェック
            if(wTypeStr === "default" || bTypeStr === "default" ){
                throw('some input format was forbidden')
            }
            
            // https://spla3.yuu26.com/からステージ情報を取得する
            let http = require('https');
            var URL = 'https://spla3.yuu26.com/api/' + bTypeStr + '/' + wTypeStr;
            console.log(URL);
            
	    // これで合ってるのか分からないけどAPI作者様の要望なのでUser-Agentにこのサイトのリンクを設定
            const options = {
                headers: {
                'User-Agent': 'IkaToolv0.1(https://zenn.dev/eph)',
                }
            };
            
	    // URLからJSONを取得して解析する
            var buffer = [];
            var val = http.get(URL, options, (res) => {
                res.setEncoding('utf8');
                if(res.statusCode !== 200){
                    throw('res.statusCode !== 200 ' + res.statusCode);
                }
		// 結果が長いと複数回に分かれるので
                res.on('data', (chunk) => {
		    // 一旦バッファーに保存して
                    buffer.push(chunk);
                }
                ).on('end', () => {
		    // バッファーをくっつけてから読む
                    var events =  buffer.join('');
                    
		    // objectにパースして
                    var parsedValue = JSON.parse(events);
                    var key1 = "results";
                    const results = parsedValue[key1];
		    // 必要なところを取ってきて文字列にする
                    switch(wTypeStr){
                        case 'next':
                        case 'now':
                            // 現在と次回のときは1つしか呼ばれない
                            results.forEach(element => {
                                const rule = element.rule.name;
                                const stages = element.stages;
                                const stage1 = stages[0].name;
                                const stage2 = stages[1].name;
                                const startTime = element.start_time;
                                const date = new Date(startTime);
                                var startTimeStr = (parseInt(date.getHours()) + 9) % 24;
                                strResponse = startTimeStr + '時からの' + bTypeStr + 'のルールは' + rule + 'で、ステージは' + stage1 + 'と' + stage2 + 'です';
                                console.log('USER_LOG: ' + strResponse);
                            });
                            break;
                        case 'schedule':
                        default:
                            // 現在と次回を指定しない場合は最大12個のスケジュールが取得される(多すぎるので3個目までしか出力しない)
                            var addStr = '';
                            for(let i = 0; i < 3 && i < results.length; i++){
                                var element = results[i];
                                const rule = element.rule.name;
                                const stages = element.stages;
                                const stage1 = stages[0].name;
                                const stage2 = stages[1].name;
                                const startTime = element.start_time;
                                const date = new Date(startTime);
                                var startTimeStr = (parseInt(date.getHours()) + 9) % 24;
                                addStr += startTimeStr + '時からの' + bTypeStr + 'のルールは' + rule + 'で、ステージは' + stage1 + 'と' + stage2 + 'です。';
                                console.log('USER_LOG: ' + addStr);
                            }
                            // strResponseの内容でawaitして監視しているのでまとめ入れる
                            strResponse = addStr;
                            break;
                    }
                });
            }).on('error', (e) => {
                this.emit(':tell', 'エラーです'+e.message);
            });
        }
        
	// 本当はhttps.getをawaitで待ちたいけどやり方が分からなかったので無理やり待つ
        while(strResponse === 'Response'){
	    // タイムアウトを設定していないのでここで一生スタックするかも
            await sleep(500);
        }
        
	// アレクサがしゃべる内容を設定
        const speakOutput = strResponse;
        return handlerInput.responseBuilder
            .speak(speakOutput)
            //.reprompt('add a reprompt if you want to keep the session open for the user to respond')
            .getResponse();
    }
};

結果

指定したワードで話しかけることで、アレクサがスケジュールを教えてくれるようになりました

シミュレーターのテスト結果

公開から、公開範囲をβテストにして自分に送ることで、echo dotやスマホのアレクサアプリからも動作確認ができました
ただやはり発音がかなりシビアです
ワードを工夫したりするしかないんでしょうか?

あと、とりあえず1か月はテストとして動くみたいですがその後はどうなるんでしょうか?公開しないといけなくなるのかな?それはめんどくさい自分用なのでこれでいいのですが…

まとめ

初めての挑戦+突貫工事でしたがひとまず所望の結果を得ることはできました

参考にさせていただいた皆さまありがとうございます
なにか問題がございましたら、修正・削除等対応いたします

さいごに

API部分の関して

実は最初はAPIを利用するつもりはなくて、この部分から自作しようと思っていました
参考にしたのはここらへん
https://dev.to/mathewthe2/intro-to-nintendo-switch-rest-api-2cm7
https://github.com/frozenpandaman/s3s
https://github.com/samuelthomas2774/nxapi-znca-api
https://github.com/mathewthe2/splatnet-desktop
ところがなかなか上手くいかず、Pythonもよく分らないんだよなーと半日ぐらいで行き詰ってしまったので今回は既存のAPIを活用させてもらいました
この辺りもできるようになったらまた記事にしたいと思います

Discussion