kawabatas技術ブログ

試してみたことを書いていきます

赤ちゃんのミルク等の時間を Google Spreadseet に記録する Alexa カスタムスキルを作る

概要

タイトルの通り、赤ちゃんのうんち、おしっこ、ミルクの時間を Google Spreadseet に記録する Alexa カスタムスキルを作ったという話です。

ほぼほぼこちらをマネさせていただきました。mm

miyataro.hatenablog.com

こちらも参考にさせていただきました。

qiita.com

作ったものはこちらになります。

github.com

背景

先日、息子が生まれました。

で、先天性の病気が見つかり、手術し、今入院中で、僕が日勤、妻が夜勤と交替しながら、一日中、病院で付き添い育児をしております。(育休を取ってる)

病院では、ミルク、うんち、おしっこの時間や量を測定していて、

時間は、泣き出した理由の見当に役立つので、家で育児するようになってからも記録したいな、と思いました。

そして、育児では両手がふさがることが多いので、家にある Amazon Echo Dot で音声で記録できればな、と思いました。

手順

1. 記録用の Spreadseet を用意

2. Spreadseet へ書き込む Apps Script を作成

ツール > スクリプト エディタ から作成できる。

records.gs。onFormSubmit はグーグルフォームからの記録用、registerXXX はアレクサからの記録用。

function onFormSubmit(e) {
  var startTime = Date.now();

  var keys = Object.keys(e.namedValues);
  var dateKey;
  var eventKey;
  var memoKey;

  keys.forEach(function(key) {
    if (key.indexOf('日時') > -1) {
      dateKey = key;
    } else if (key.indexOf('イベント') > -1) {
      eventKey = key;
    } else if (key.indexOf('メモ') > -1) {
      memoKey = key;
    }
  });

  var date = e.namedValues[dateKey][0] ? new Date(e.namedValues[dateKey][0]) : new Date();
  var events = e.namedValues[eventKey][0].split(/,\s*/);
  var memo = e.namedValues[memoKey][0];

  if (events.indexOf(TYPE_NAME.poo) > -1) {
    records.appendJournalRecord(date, TYPE.POO, memo);
  }
  if (events.indexOf(TYPE_NAME.pee) > -1) {
    records.appendJournalRecord(date, TYPE.PEE, memo);
  }
  if (events.indexOf(TYPE_NAME.milk) > -1) {
    records.appendJournalRecord(date, TYPE.MILK, memo);
  }
  if (events.indexOf(TYPE_NAME.health) > -1) {
    records.appendJournalRecord(date, TYPE.HEALTH, memo);
  }

  var executionTime = Date.now() - startTime;
  Logger.log('onFormSubmit took ' + executionTime + ' ms');
}

function registerPoo() {
  var startTime = Date.now();

  records.appendJournalRecord(new Date(), TYPE.POO);

  var executionTime = Date.now() - startTime;
  Logger.log('registerPoo took ' + executionTime + ' ms');
}

function registerPee() {
  var startTime = Date.now();

  records.appendJournalRecord(new Date(), TYPE.PEE);

  var executionTime = Date.now() - startTime;
  Logger.log('registerPee took ' + executionTime + ' ms');
}

function registerPooAndPee() {
  var startTime = Date.now();

  records.appendJournalRecord(new Date(), TYPE.POO);
  records.appendJournalRecord(new Date(), TYPE.PEE);

  var executionTime = Date.now() - startTime;
  Logger.log('registerPooAndPee took ' + executionTime + ' ms');
}

function registerMilk() {
  var startTime = Date.now();

  records.appendJournalRecord(new Date(), TYPE.MILK);

  var executionTime = Date.now() - startTime;
  Logger.log('registerMilk took ' + executionTime + ' ms');
}

var records = {};

records.getSheet = function () {
  if (!records.sheet) {
    records.sheet = SpreadsheetApp.getActive().getSheetByName('records');
  }
  return records.sheet;
}

records.appendJournalRecord = function (date, type, memo) {
  var startTime = Date.now();
  Logger.log('appendJournalRecord started');

  records.getSheet().appendRow(records.createJournalRecordRowContent(date, type, memo));

  var executionTime = Date.now() - startTime;
  Logger.log('appendJournalRecord took ' + executionTime + ' ms');
};

records.createJournalRecordRowContent = function (date, type, memo) {
  var row = [];
  row.push("'" + date.toLocaleDateString());
  row.push("'" + date.toLocaleTimeString().replace(/[^:0-9]/g, ''));
  row.push(TYPE_NAME[type]);
  if (memo) {
    row.push(memo);
  }
  return row;
}

const.gs

var COLUMN = {
  DATE: 'date',
  TIME: 'time',
  EVENT: 'event',
  MEMO: 'memo'
}

var TYPE = {
  POO: 'poo',
  PEE: 'pee',
  MILK: 'milk',
  HEALTH: 'health'
};

var TYPE_NAME = {
  'poo': 'うんち',
  'pee': 'おしっこ',
  'milk': 'ミルク',
  'health': '体調'
};

var TYPE_BY_NAME = {};
Object.keys(TYPE_NAME).forEach(function(key) {
  TYPE_BY_NAME[TYPE_NAME[key]] = key;
});

3. Apps Script をAPIとして公開

f:id:kawabatas:20190906155139p:plain

ここが一番てこづった。

Google Cloud Platform プロジェクトを作成し、Apps Script を関連づける。

f:id:kawabatas:20190906155653p:plain

そして、「GCP プロジェクトの Apps Script API を有効化」「OAuth 2.0 クライアントIDとシークレットの取得」「OAuth 2.0 Playground でアクセストークンとリフレッシュトークンの取得」

参考はこちら。

qiita.com

4. Alexaスキルを開発

Alexaスキルの開発は初めてだったが、公式が動画教材を作っていて、とてもわかりやすかった。

developer.amazon.com

Alexa-hostedスキルで開発すれば alexa developer console 上で開発が完結する。すごい。

developer.amazon.com

f:id:kawabatas:20190906161000p:plain

対話モデルの設定

alexa developer console メニュー上のビルド > JSONエディター から json をアップロード。

そして、モデルを保存し、モデルをビルド。

{
    "interactionModel": {
        "languageModel": {
            "invocationName": "リクログ",
            "intents": [
                {
                    "name": "AMAZON.CancelIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.HelpIntent",
                    "samples": [
                        "使い方",
                        "ヘルプ"
                    ]
                },
                {
                    "name": "AMAZON.StopIntent",
                    "samples": [
                        "ありがとう",
                        "終了",
                        "終わり",
                        "ストップ"
                    ]
                },
                {
                    "name": "RegisterPooIntent",
                    "slots": [],
                    "samples": [
                        "うんち"
                    ]
                },
                {
                    "name": "RegisterPeeIntent",
                    "slots": [],
                    "samples": [
                        "おしっこ"
                    ]
                },
                {
                    "name": "RegisterPooAndPeeIntent",
                    "slots": [],
                    "samples": [
                        "うんちとおしっこ",
                        "おしっことうんち"
                    ]
                },
                {
                    "name": "RegisterMilkIntent",
                    "slots": [],
                    "samples": [
                        "ミルク"
                    ]
                },
                {
                    "name": "AMAZON.NavigateHomeIntent",
                    "samples": []
                }
            ],
            "types": []
        }
    }
}

Lambda 関数の作成

alexa developer console メニュー上のコードエディタ。

package.json に googleapis を追加。

"dependencies": {
    "ask-sdk-core": "^2.6.0",
    "ask-sdk-model": "^1.18.0",
    "aws-sdk": "^2.326.0",
    "googleapis": "^25.0.0"
  }

gas-accessor.js を作成。

const google = require('googleapis');
const OAuth2 = google.auth.OAuth2;

const CLIENT_ID = process.env['CLIENT_ID'];
const CLIENT_SECRET = process.env['CLIENT_SECRET'];
const ACCESS_TOKEN = process.env['ACCESS_TOKEN'];
const REFRESH_TOKEN = process.env['REFRESH_TOKEN'];
const SCRIPT_ID = process.env['SCRIPT_ID'];
const DEV_MODE = process.env['DEV_MODE'] ? /^true$/i.test(process.env['DEV_MODE']) : false;

const gasAccessor = {};

gasAccessor.executeFunction = function (functionName, callback, opt_parameter) {
    var startTime = Date.now();

    console.log('executeFunction started [functionName=' + functionName + ', parameter=' + opt_parameter);
    const auth = new OAuth2(CLIENT_ID, CLIENT_SECRET);
    auth.setCredentials({
        access_token: ACCESS_TOKEN,
        refresh_token: REFRESH_TOKEN
    });
    const script = google.script('v1');
    script.scripts.run({
        auth: auth,
        scriptId: SCRIPT_ID,
        resource: {
            function: functionName,
            parameters: [opt_parameter],
            devMode: DEV_MODE
        }
    }, (err, result) => {
        var turnAroundTime = Date.now() - startTime;
        console.log(functionName + '  API execution took ' + turnAroundTime + ' ms');
        if (err || result.data.error) {
            console.error(JSON.stringify(err));
            console.error(JSON.stringify(result.data.error));
            throw 'API Execution Failure';
        } else {
            console.log(JSON.stringify(result.data.response));
            callback(result.data.response.result);
            var callbackExecutionTime = Date.now() - startTime - turnAroundTime;
            console.log('callback execution took ' + callbackExecutionTime + ' ms');
        }
    });
};

module.exports = gasAccessor;

index.js を編集。

// This sample demonstrates handling intents from an Alexa skill using the Alexa Skills Kit SDK (v2).
// Please visit https://alexa.design/cookbook for additional examples on implementing slots, dialog management,
// session persistence, api calls, and more.
const Alexa = require('ask-sdk-core');
const gasAccessor = require('./gas-accessor');

const LaunchRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
    },
    handle(handlerInput) {
        const speakOutput = 'こんにちは。リクログでは、うんち、おしっこ、ミルクが記録できます。何をしますか?';
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};
const RegisterPooIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'RegisterPooIntent';
    },
    handle(handlerInput) {
        gasAccessor.executeFunction('registerPoo', function (result) {
            console.log(`IntentHandler executed gasAccessor.executeFunction registerPoo.`);
        }.bind(this));
        const speakOutput = 'うんちを記録しました。';
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .getResponse();
    }
};
const RegisterPeeIntentHandler = {
  canHandle(handlerInput) {
      return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
          && Alexa.getIntentName(handlerInput.requestEnvelope) === 'RegisterPeeIntent';
  },
  handle(handlerInput) {
      gasAccessor.executeFunction('registerPee', function (result) {
          console.log(`IntentHandler executed gasAccessor.executeFunction registerPee.`);
      }.bind(this));
      const speakOutput = 'おしっこを記録しました。';
      return handlerInput.responseBuilder
          .speak(speakOutput)
          .getResponse();
  }
};
const RegisterPooAndPeeIntentHandler = {
  canHandle(handlerInput) {
      return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
          && Alexa.getIntentName(handlerInput.requestEnvelope) === 'RegisterPooAndPeeIntent';
  },
  handle(handlerInput) {
      gasAccessor.executeFunction('registerPooAndPee', function (result) {
          console.log(`IntentHandler executed gasAccessor.executeFunction registerPooAndPee.`);
      }.bind(this));
      const speakOutput = 'うんちとおしっこを記録しました。';
      return handlerInput.responseBuilder
          .speak(speakOutput)
          .getResponse();
  }
};
const RegisterMilkIntentHandler = {
  canHandle(handlerInput) {
      return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
          && Alexa.getIntentName(handlerInput.requestEnvelope) === 'RegisterMilkIntent';
  },
  handle(handlerInput) {
      gasAccessor.executeFunction('registerMilk', function (result) {
          console.log(`IntentHandler executed gasAccessor.executeFunction registerMilk.`);
      }.bind(this));
      const speakOutput = 'ミルクを記録しました。';
      return handlerInput.responseBuilder
          .speak(speakOutput)
          .getResponse();
  }
};
const HelpIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.HelpIntent';
    },
    handle(handlerInput) {
        const speakOutput = 'うんち、おしっこ、ミルクが記録できます。何をしますか?';

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};
const CancelAndStopIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && (Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.CancelIntent'
                || Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.StopIntent');
    },
    handle(handlerInput) {
        const speakOutput = '終了します。バイバイ、またね。';
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .getResponse();
    }
};
const SessionEndedRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'SessionEndedRequest';
    },
    handle(handlerInput) {
        // Any cleanup logic goes here.
        return handlerInput.responseBuilder.getResponse();
    }
};

// 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';
    },
    handle(handlerInput) {
        const intentName = Alexa.getIntentName(handlerInput.requestEnvelope);
        const speakOutput = `You just triggered ${intentName}`;

        return handlerInput.responseBuilder
            .speak(speakOutput)
            //.reprompt('add a reprompt if you want to keep the session open for the user to respond')
            .getResponse();
    }
};

// Generic error handling to capture any syntax or routing errors. If you receive an error
// stating the request handler chain is not found, you have not implemented a handler for
// the intent being invoked or included it in the skill builder below.
const ErrorHandler = {
    canHandle() {
        return true;
    },
    handle(handlerInput, error) {
        console.log(`~~~~ Error handled: ${error.stack}`);
        const speakOutput = `エラーが発生しました。もう一度、お願いします。`;

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};

// The SkillBuilder acts as the entry point for your skill, routing all request and response
// payloads to the handlers above. Make sure any new handlers or interceptors you've
// defined are included below. The order matters - they're processed top to bottom.
exports.handler = Alexa.SkillBuilders.custom()
    .addRequestHandlers(
        LaunchRequestHandler,
        RegisterPooIntentHandler,
        RegisterPeeIntentHandler,
        RegisterPooAndPeeIntentHandler,
        RegisterMilkIntentHandler,
        HelpIntentHandler,
        CancelAndStopIntentHandler,
        SessionEndedRequestHandler,
        IntentReflectorHandler, // make sure IntentReflectorHandler is last so it doesn't override your custom intent handlers
        )
    .addErrorHandlers(
        ErrorHandler,
        )
    .lambda();

5. 開発したAlexaスキルをテスト

alexa developer console メニュー上のテストより試す。

「リクログでうんち」

そして、amazon echo dot 実機で試す。

「アレクサ、リクログでうんち」

以上。

実際に使ってみて変更を加えるかも。