【Alexa】Connpass用の受付スキルを作ってみた #Alexa #AlexaDevs

せーのでございます。ここ数年、結構な頻度で勉強会を開いていまして、その度に「めんどくさいなあ」と思っていた受付処理をスキル化してみました。

※この記事は基本的なスキルの作成法は解説致しません。もしわからない用語が出てきた時はDevelopers.IOのAlexaカテゴリから検索してみましょう。

ConnpassはQRコードが出てこない

勉強会を開いて参加者を募集する時には、通常Connpass, EventRegist, Peatixなどのイベント管理用のサービスを使います。
それぞれのサービスには一長一短あるのですが、私の開く勉強会は大抵が無料で、とにかく沢山の人に来て欲しいので、拡散力の強いConnpassをメインに使っています。

ですがこのConnpass、一つ不便な点があります。それは当日の受付の際に、参加者の受付票にQRコードが出てこない、という点です。

毎回毎回、参加者の方に受付票を見せてもらっては、同じ番号を目視で参加者一覧から探しチェックする、という作業を繰り返すことになります。10人20人ならまだ何とかなりますが、これが50人100人のイベントになると、相当苦痛です。

そこで、受付番号を言えば一覧から自動的に探してチェックを入れてくれるスキルを作ってみました。

こちらが完成形です。

最近はAAJUG(Amazon Alexa Japan User Group)というイベントを開くことが多いので、「AAJUG受付」という名前にしてみました。

構成図

まずシステム構成を決めます。Connpassの参加者一覧はCSVでダウンロードできるので、普段はそのCSVをそのままGoogleのスプレッドシートに貼り付けて、一番端にチェックボックスをつけています。
ですので、AlexaからのリクエストをLambdaが受け取ったら、そこからスプレッドシートを叩き、GAS(Google App Script)で表の内部を検索、もし番号が一致したらチェックボックスを入れて背景色を変えることにします。

やってみた

ハッピーパス

まずはハッピーパスを作ります。

U「Alexa, AAJUG受付を開いて」
A「AAJUG受付です。受付を開始します。受付番号を教えてください。」
U「1234567番で」
A「せーのさんの受付が完了しました。ようこそAAJUGへ!」

こんな感じになります。簡単ですね。

VUI設計

次にVUI設計を作ります。と言っても今回はスロットが受付番号だけなので、非常に簡単な設計になります。

セリフを考える

次にそれぞれのVUIに合うようなセリフを考えます。

実際にコーディングする時はもう少し増やすかもしれません。

これで大枠の設計が出来上がりました。ではコーディングに移ります。

開発者コンソール

今回は規模も小さいのでAlexa-hostedを使います。 開発者コンソールから新しいスキルを作成し、Alexa-hosted、つまりバックエンドに「Alexaがホスト」を選択します。

ビルド画面に移ったら、呼び出し名を「エージャグ受付」にし、「registIntent」というインテントを作成、中に「ticketnumber」というスロットを作り、AMAZON.FOUR_DIGIT_NUMBERにセットします。

ticketnumberの中に遷移し、ダイアログモデルとしてプロンプトとサンプル発話を作ります。

今回はLambda側でスロット値のチェックをするので、作ったらインテントに戻って「ダイアログデリゲートのルール」を「オートデリゲートを無効化」にしましょう。これでLambda側にデリゲートされます。

最後に「ビルド」ボタンをクリックしてモデルをビルドすればインタラクションモデルは完成です。

GASを作成

次にスプレッドシート側の検索ロジックを作ります。
新規にスプレッドシートを作成したら、connpassから落としてきたCSVファイルをExcelなどで開いてまるっとコピーし、貼り付けます。貼り付けたらA列に列を挿入し、参加者のいる行を選択して[挿入] -> [チェックボックス]でチェックボックスをつけます。シート名を「出欠シート」とします。

画像は見やすいように要らない行を消していますが、特にそのままでも構いません。

次に[ツール] -> [スクリプトエディタ]でGASのエディタを表示します。

GASが表示されたら、関数「findParticipant」を作ります。
チケット番号を元にB列(受付番号)をループして検索し、同じ値が見つかったらチェックボックスをON、背景色を薄い緑に変更してC列(表示名)を元にレスポンスを作ります。見つからなかったら「missing」という文字列を返します。

function findParticipant(sheet, ticketNumber) {
  var values = sheet.getDataRange().getValues();

  for (var i = values.length - 1; i > 0; i--) {
    var val = values[i][1];
    if (val == ticketNumber) {
      var rng = sheet.getRange(i + 1, 1);
      rng.activate();
      rng.setValue(true); // チェックボックスをつける
      rng = sheet.getRange(i + 1, 1, 1, 8);
      rng.setBackground("palegreen");
      return values[i][2] + "さんの受付が完了しました。ようこそエージャグへ!";
    }
  }

  return "missing";
}

次にこれを外から叩く用にインターフェース用の関数「doGet」を作ります。ここでは「出欠シート」というシートを指定し、引数でticketnumber=受付番号を取得して、作ったfindParticipantを叩き、結果をそのまま文字列で返します。

function doGet(e) {
   var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('出欠シート');
   var res = ContentService.createTextOutput();
   var ticketNumber = e.parameter.ticketnumber;
   var retval = findParticipant(sheet, ticketNumber);  

   res.setContent(retval);
   return res;

}

一応テストも書いておきます。今回は @munieru_jp さんが作った「GASUnit」 というテストツールを使って書いてみます。スプレッドシートに実際にある受付番号を引数に否定した時に正常に返ってくるか、スプレッドシートにない番号を引数にした時に「missing」が返ってくるか、をテストしています。

var exports = GASUnit.exports
var assert = GASUnit.assert

function test_findParticipant() {
  exports({
    'findParticipant' : {
      '正常パターン': function() {
        var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('出欠シート');
        var ticketNumber = '1682844';
        resmsg = '武将猛牛さんの受付が完了しました。ようこそエージャグへ!';
        assert(findParticipant(sheet, ticketNumber) === resmsg)
      }, 
      '番号の人が見つからなかった場合': function() {
        var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('出欠シート');
        var ticketNumber = '1682234';
        assert(findParticipant(sheet, ticketNumber) === 'missing')
      }
    }
  })
}

テストを実行します。テストの関数[test_findParticipant]を選んで実行するだけです。

[表示] -> [ログ]で結果を確認します。OKのようです。

出来たGASを公開します。[公開] -> [ウェブアプリケーションとして導入]を選択します。

「プロジェクト バージョン」を「new」にし、公開ボタンをクリックします(2回め以降は「更新」ボタンになります)。

できたURLをコピーしておきます。これでGAS側は完成です。

Lambdaの実装

最後にバックエンドのLambdaを作ります。開発者コンソールから「コードエディタ」を開き、[package.json]を開きます。

Alexa Utilitiesを使いたいので[ask-sdk-core][ask-sdk-model]のバージョンを上げます。またAPIをWeb経由で叩くので[request]と[request-promise]を追加します。この状態で一旦保存し、デプロイします。

さて、いよいよコードに取り掛かります。
まずLaunch RequestのHandlerです。

const LaunchRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
    },
    handle(handlerInput) {
        const speechText = 'エージャグ受付です。';

        return handlerInput.responseBuilder
            .addDelegateDirective({
                name: 'registIntent',
                confirmationStatus: 'NONE',
                slots: {}
            })
            .speak(speechText)
            .reprompt(speechText)
            .getResponse();
    }
};

今回のスキルは「受付番号を聞いてチェックする」という目的がハッキリしているので、あいさつをしたらそのままregistIntentにchainingしています。 またcanHandleの条件式は全てAlexa Utilitiesに変えてあります。

つぎに処理ロジックとして[regist]という関数を作成します。

const request = require('request-promise');

const regist = (ticketnumber) => {
    let url = "https://script.google.com/macros/s/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/exec?ticketnumber=" + ticketnumber;
    var speechText = "";
    console.log(url);
    return request(url);
};

request-promiseを使ってPromise形式でrequestを使います。URLには先程コピーしたGASのURLを使います。
request-promiseをrequireしたオブジェクト[request]をそのまま返していますので、戻り値の型はPromise型となります。

次にこの関数を呼ぶregistIntentのHandlerを作ります。

const RegistIntentHandler = {
    canHandle(handlerInput) {

        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'registIntent'
            && Alexa.getDialogState(handlerInput.requestEnvelope) !== 'COMPLETED';
    },
    async handle(handlerInput) {

        const ticketnumber = Alexa.getSlotValue(handlerInput.requestEnvelope, "ticketnumber");
        console.log(ticketnumber);
        let speechText = "";
        await regist(ticketnumber)
        .then(val => {
            console.log("speechText: " + val);
            speechText = val;
        });

        let response = handlerInput.responseBuilder;
        if (speechText === "missing") {
            console.log("値が取れなかった");
            handlerInput.requestEnvelope.request.intent.slots.ticketnumber.value = undefined;
            speechText = "<say-as interpret-as='digits'>" + ticketnumber + "</say-as>番の受付番号が見つかりません。もう一度言ってもらえますか?";
            response.reprompt(speechText)
            .addElicitSlotDirective('ticketnumber');
        }

        return response
        .speak(speechText)
        .getResponse();
    }
};

ポイントとしてはasyncをつけて、処理ロジックであるregistをawaitさせて同期化しています。
次に受付番号が見つからなかった時はスロットの値を空にして、ElicitSlotで直接受付番号をもう一度聞くように誘導しています。見つかった場合はGAS側のメッセージをそのまま言って終了しています。responseBuilderを変数にしておいて、if文でそれぞれ必要なメソッドを加える形にしておけば、最終的にgetResponse()メソッドで、それまで加えられてきたメソッドがそのままAlexaに返ります。
細かいところで言うと、スロットの値は数字として渡ってくるので、そのまま読むと「ひゃくにじゅうさんまん よんせんごひゃくろくじゅうなな番の受付番号は〜」となります。それをSSMLの「say-as interpret-as=‘digits’」タグで一つずつ数字を読むようにしています。

後はHelpIntentやStopIntentの返事を日本語に変更し、それぞれのハンドラーをskillBuilderに登録すれば完成です。

exports.handler = Alexa.SkillBuilders.custom()
    .addRequestHandlers(
        LaunchRequestHandler,
        RegistIntentHandler,
        HelpIntentHandler,
        CancelAndStopIntentHandler,
        SessionEndedRequestHandler,
        IntentReflectorHandler) 
    .addErrorHandlers(
        ErrorHandler)
    .lambda();

保存してデプロイします。

これで完成となります。

テスト

完成したらテストしてみましょう。受付番号があった時は一番最初にお見せした通りです。

受付番号が見つからない時は聞き返す形になります。

成功ですね。

まとめ

以上、今回は「受付スキル」を作ってみました。Connpassに限らず、スプレッドシートを使った出席管理などにも応用が効くかと思います。是非参考にしてみてください。