この記事は ZOZO #1 Advent Calendar 2021 16日目の記事になります。
はじめに
Alexaスキル開発にはアカウントリンクと呼ばれる機能があり、GoogleやAmazon、GitHubなどが提供する外部サービスとOAuth認証を使って連携することができます。
今回はGoogleカレンダーAPIと連携し、Alexaがカレンダーに登録されたイベントを読み上げるスキルを開発したので紹介します。
例)
- 今日の予定を教えて
- ▷ 今日の予定は2件あります。1件目は、10時からのQiita投稿、2件目は、12時30分からのランチです。
- 明日の予定
- ▷ 今日の予定は1件あります。1件目は、10時からの歯磨きです。
- 昨日の予定を教えて
- ▷ スケジュールがありませんでした。
Alexaスキル
そもそもAlexaスキルとは、EchoデバイスやAlexa上で利用できるAlexa専用のアプリのようなものです。ユーザは音声を使ってスキルを操作することで、例えば明日の気象情報を聞いたり、バスの運行情報を聞いたりできます。
代表的なスキルとして以下のようなものがあります。
料金
AWSが提供するAlexaスキル開発におけるリソース利用料金については、AWSの無料利用枠内に制限されます。
Alexa開発者アカウントごとに、Amazonはリソース使用量を次の制限に設定します。
・ AWS Lambda: Alexaがホストするエンドポイントで使用される無制限の無料のAWSLambdaリクエスト。
・ Amazon S3: 25GBのAmazonS3ストレージ、および1か月あたり250GBのデータ転送。
・ Amazon DynamoDB: 25GBのAmazonDynamoDBストレージ、1か月あたり1,000万回の読み取りと書き込み。
・ AWS CodeCommit:50GB-月間ストレージと10,000Gitリクエスト/月。
出典:https://developer.amazon.com/ja-JP/docs/alexa/hosted-skills/usage-limits.html
Alexaスキル作成
Googleカレンダーのイベントを読み上げるスキルを作成します。
Alexa Developer Consoleにアクセスしてログイン後、**「スキルの作成」**を押します。
スキル名に**「サンプルカレンダー」と入力し、「スキル作成」**を押します。
テンプレート選択画面が表示されるので、**「スクラッチで作成」を選択し、「テンプレートで続ける」**を押します。
ビルドが行われ、しばらく待つとAlexaスキルが作成されます。
作成したスキルはAWS Lambda上にデプロイされ、お手持ちのEchoデバイスから「サンプルカレンダーを開いて」と話しかけることでスキルを呼び出せます。
作成したAlexaスキルでHello World
作成したAlexaスキルの動作チェックのために、**「テスト」タブを選択し、「非公開」になっているところを「開発中」**へ変更します。
Alexa Developer Consoleでは、Echoデバイスを使わなくてもAlexaシュミレータを使うことでテキストベースで簡単に動作確認できます。
テキストフィールドに**「ハロー」**と入力して応答が返ってくることを確認してください。
アカウントリンクの有効化
GoogleカレンダーAPIと連携するために「アカウントリンク」を有効化します。
**「ビルド」タブを選択し、「アカウントリンク」**を選択します。
アカウントリンクページに表示されるスイッチを画像の通り有効化します。
**「Alexaのリダイレクト先URL」**は後でGCP上でOAuthクライアントIDを発行するとき使うので、ページをそのまま開いておくかメモしておきましょう。
Google OAuth
GoogleカレンダーAPIをAlexaスキルから扱えるようにするため、GCPで新規プロジェクトを作成し、OAuthの設定を行います。
まずは、GCPにログインします。
GCPプロジェクトの作成
新規プロジェクトを作成します。
ここでプロジェクト名は**「SampleCalendar」**としていますが、何でもOKです。
GoogleカレンダーAPIの有効化
作成したプロジェクトのダッシュボードに切り替わっていることを確認し、次に、APIの有効化を行います。
検索窓に**「Google Calendar API」**と入力しサジェストされたページへ遷移します。
**「有効にする」**を押して、新規作成したプロジェクトでGoogleカレンダーAPIを使えるようにします。
OAuth同意画面
**「OAuth同意画面」**の設定を行います。
メニューから**「APIとサービス」->「OAuth同意画面」**を選択します。
User Typeは、**「外部」**を選択し、「作成」を押します。
アプリ名に**「SampleCalendar」を入力、「ユーザサポートメール」、「デベロッパーの連絡先情報」**にご自身のメールアドレスを入力して「保存して次へ」を選択します。
次に、**「スコープを追加または削除」を押してOAuthのスコープを設定します。
今回は、GoogleカレンダーAPIのイベントを読み込めればいいので、フィルターで「calendar.events.readonly」**を検索して表示されたスコープを選択し追加します。
(参考:GoogleカレンダーAPIドキュメント)
次に、テストユーザの追加を行います。
ここで追加するユーザはカレンダー情報を取得したいご自身のメールアドレスになります。
Google OAuth認証情報の作成
左のタブから**「認証情報」を選択し「認証情報を作成」>「OAuthクライアントID」**を選択します。
アプリケーションの種類は**「ウェブアプリケーション」を選択し、名前は適当に付けましょう。
そして、承認済みのリダイレクトURIですが、先程のアカウントリンク**のページでメモしておいたものをここに貼り付けます。
最後に、作成を押すと、クライアントIDとクライアントシークレットが発行されます。
次のアカウントリンクの設定(続き)で使うのでメモしておきましょう。
アカウントリンクの設定(続き)
先程作成したクライアントIDやシークレットとその他情報をアカウントリンクページの赤枠で囲われた箇所に入力し、保存します。
- Authorization Grant種別を選択 -> Auth Code Grant
- Web認証画面のURI -> https://accounts.google.com/o/oauth2/auth?access_type=offline&approval_prompt=force
- アクセストークンのURI -> https://accounts.google.com/o/oauth2/token
- ユーザのクライアントID -> 発行したクライアントID
- Client Secret -> 発行したクライアントシークレット
- ユーザの認証スキーム -> HTTP Basic認証(推奨)
- スコープ -> https://www.googleapis.com/auth/calendar.events.readonly
スマホでアカウントリンク設定
これまでの設定で「サンプルカレンダー」スキルでは、アカウントリンクを使ってGoogle OAuth認証できるようになっていると思うので、上手くできるか検証してみましょう。
スマホのAlexaアプリから「スキルとゲーム」を選択し、「有効なスキル」>「開発」>**「サンプルカレンダー」**を選択し、設定を押します。
**「アカウントをリンク」**を選択するとGoogleログインが求められるので進めていきます。
このとき設定していたスコープ「calendar.events.readonly」であることを確認しましょう。
エラーなく「アカウントをリンク済み」と表示されていれば成功です。
Alexaスキル開発
準備はこれで終了。これからコーディングを行います。
以下の流れで開発を行います。
- 音声トリガー(インテント)の作成
- 作成したインテントが呼ばれた際の振る舞いをコーディング
- デプロイ
- テスト
インテント
インテントとは
インテントとは、ユーザがスキルに求める動作を意味します。
例えば、ユーザがスキルを使ってカレンダーの予定を聞いた場合、どのような予定があるかユーザに教えるインテントが起動します。
一方で、ユーザが予定を登録したい場合、求める動作は異なるので別のインテントを用意しなければいけません。
インテントの作成
Alexaスキルでは、インテントごとに音声による起動トリガーを用意します。
今回は予定を読み込むインテントを作成します。
ページ上部の**「ビルド」タブを選択し、左の「対話モデル」>「インテント」**を押します。
**「インテントを追加」を選択、名前を「ReadEventsIntent」として「カスタムインテントを作成」**します。
インテントの起動トリガー追加
サンプル発話を5つほど画像の通り追加します。
「予定を教えて」、「予定」などのワードをトリガーにこのインテントは呼ばれるようになります。
最後に**「モデルをビルド」**を選択し、音声モデルを作成しましょう。
日付情報を受け取れるようにする
今日だけだと、限定的な使い方しかできないので、柔軟に日付を変更できるようにしましょう。
サンプル発話に追加したもので「今日」と書かれたワードを範囲選択すると「既存のスロットを選択」と表示されます。
今日の部分を変数化して、明日や昨日といったワードを受け取れるようにしたいので**「dateTime」**と入力し新しいスロットを追加します。
ページ下部にインテントスロットとして先程追加した**「dateTime」が現れるので、スロットタイプを「AMAZON.DATE」**に変更しましょう。
これで、「今日」、「明日」といったワードだけではなく、クリスマス、こどもの日、12月25日といった日付に関連するワードを"12-25"のような文字列に変換してくれます。
最後に、必ず**「ビルドを実行」**を押します。(これしないとエラーになります)
コーディング
**「コードエディタ」**タブを選択し、いよいよプログラムを書いていきます。
依存関係の追加
まず、APIや日付を扱うために**「axios」と「moment-timezone」**パッケージを追加します。
{
"name": "hello-world",
"version": "1.2.0",
"description": "alexa utility for quickly building skills",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Amazon Alexa",
"license": "Apache License",
"dependencies": {
"ask-sdk-core": "^2.7.0",
"ask-sdk-model": "^1.19.0",
"aws-sdk": "^2.326.0",
"axios": "^0.24.0",
"moment-timezone": "^0.5.34"
}
}
index.jsの編集
作成した「ReadEventsIntent」をindex.jsに追加したり、API処理を書いたり色々編集します。(コピペでOK)
詳しい説明は後述します。
/* *
* 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 axios = require('axios');
const moment = require('moment-timezone');
const LaunchRequestHandler = {
canHandle(handlerInput) {
return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
},
handle(handlerInput) {
const speakOutput = 'あなたのカレンダーの予定を読み上げます。予定を教えて、と聞いてください。';
return handlerInput.responseBuilder
.speak(speakOutput)
.reprompt(speakOutput)
.getResponse();
}
};
const ReadEventsIntentHandler = {
canHandle(handlerInput) {
return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
&& Alexa.getIntentName(handlerInput.requestEnvelope) === 'ReadEventsIntent';
},
async handle(handlerInput) {
const timeZone = 'Asia/Tokyo';
// インテント作成時追加した「dateTime」スロットに入った値を取得します「11-23」、「12-24」などの日付が入っている。
const requestedDateTime = handlerInput.requestEnvelope.request.intent.slots.dateTime.value;
// 指定された日付と現在の日付との差分を取得
const offsetDay = getDateTimeDiff(timeZone, requestedDateTime);
// 日付を「今日」、「11月23日」などAlexaが自然に読めるようにフォーマットする
const dateText = getDateTimeText(timeZone, requestedDateTime, offsetDay);
// GoogleカレンダーAPIを叩くためのアクセストークン
const accessToken = handlerInput.requestEnvelope.session.user.accessToken;
if (accessToken === undefined) {
// リンクする必要があることをユーザに伝えるためにアカウントリンクカードを返す
const speechText = "スキルを利用するにはグーグルでログインを許可してください";
return handlerInput.responseBuilder
.speak(speechText)
.withLinkAccountCard()
.getResponse();
}
// Google Calendar APIを叩いてイベント取得
const events = await getEvents(accessToken, timeZone, offsetDay);
if (!events.length) {
const speakOutput = "スケジュールがありませんでした。";
return handlerInput.responseBuilder
.speak(speakOutput)
.getResponse();
}
// スケジュールを読める形にフォーマットする
const speakOutput = createSpeackTextforCheckingSchedule(events, timeZone, dateText);
return handlerInput.responseBuilder
.speak(speakOutput)
.getResponse();
}
};
/* 指定された日付と現在の日付との差分を取得 */
const getDateTimeDiff = (timeZone, dateTime) => {
// undefinedのときは0(今日)とする
return dateTime === undefined ? 0 : moment.tz(dateTime, timeZone).diff(moment.tz(timeZone).startOf('days'), 'days');
};
/* 読み上げるための日付テキストを取得 */
const getDateTimeText = (timeZone, dateTime, offsetDay) => {
const dateTexts = {"-2": "一昨日", "-1": "昨日", "0": "今日", "1": "明日", "2": "明後日"};
// 対応する日付用語があればこれで読み上げる
if (offsetDay in dateTexts) {
return dateTexts[offsetDay];
}
return moment.tz(dateTime, timeZone).format('MM月DD日');
};
/* カレンダーの予定を読むためのテキストを生成 */
const createSpeackTextforCheckingSchedule = (events, timeZone, dateText) => {
const scheduleText = events.map((event, index) => {
const title = event.summary;
// 終日や開始日時が取得できないイベントは、件名だけ読み上げる
if (!event.start.dateTime) {
return `${index+1}件目は、${title}`;
}
const startDateTime = moment.tz(event.start.dateTime, timeZone);
const hour = startDateTime.get('hour');
const minute = startDateTime.get('minute');
if (minute === 0) {
return `${index+1}件目は、${hour}時からの${event.summary}`
}
return `${index+1}件目は、${hour}時${minute}分からの${event.summary}`
}).join('、');
const speakText = events.length > 0 ? `${dateText}の予定は、${events.length}件あります。${scheduleText}です。` : `${dateText}の予定はありません。`;
return speakText;
}
/* カレンダーのイベント情報を取得 */
const getEvents = async (accessToken, timeZone, offsetDay=0) => {
// GETリクエストの作成
const apiURL = 'https://www.googleapis.com/calendar/v3/calendars/primary/events';
const now = moment.tz(timeZone);
// offsetDayが0であれば今日、1であれば明日の日時でオブジェクトを生成する
const dateTime = now.set('day', now.get('day') + offsetDay);
// 指定されたタイムゾーンにおいての0時と23時59分59秒をutc時刻に変換
const timeMin = moment(dateTime.startOf("day").format()).utc().format();
const timeMax = moment(dateTime.endOf("day").format()).utc().format();
// 日付関係なく定期イベントの初回が含まれてしまうので呼ばれないようにする
const singleEvents = true;
const params = {singleEvents, timeMin, timeMax};
const headers = {
Authorization: `Bearer ${accessToken}`
};
// API コール
const events = await axios.get(apiURL, {params, headers})
.then(response => response.data.items);
return events;
};
/* ----------------HelpやErrorなど既存のものを使う(省略)------------------*/
/** index.jsの最下部
* This handler 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,
ReadEventsIntentHandler, // HelloWorldIntentHandlerと入れ替える
HelpIntentHandler,
CancelAndStopIntentHandler,
FallbackIntentHandler,
SessionEndedRequestHandler,
IntentReflectorHandler)
.addErrorHandlers(
ErrorHandler)
.withCustomUserAgent('sample/hello-world/v1.2')
.lambda();
デプロイ
最後に**「デプロイ」**を選択し、ここまでの変更を反映しましょう。
コードの説明
スキル起動時、最初に呼ばれる箇所
LaunchRequestHandler
はスキルがユーザから呼ばれた際に最初に動作するハンドラーになります。
ここでは、ユーザにスキルの操作方法を伝えると親切なので、**「あなたのカレンダーの予定を読み上げます。予定を教えて、と聞いてください。」**を話すようにしています。
const LaunchRequestHandler = {
canHandle(handlerInput) {
return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
},
handle(handlerInput) {
const speakOutput = 'あなたのカレンダーの予定を読み上げます。予定を教えて、と聞いてください。';
return handlerInput.responseBuilder
.speak(speakOutput)
.reprompt(speakOutput)
.getResponse();
}
};
ReadEventsIntentの追加
ReadEventsIntentが呼ばれたい際の動作を書いています。
大まかな流れは以下の通りです
- 「dateTime」スロットに入った日付を取得
- アカウントリンクによって生成されたAccessTokenを取得
- AccessTokenを使って指定された日付のスケジュールをGoogle Calendar APIから取得
- APIレスポンスをAlexaが読める形にフォーマット
- Alexaに読んでもらう
const ReadEventsIntentHandler = {
canHandle(handlerInput) {
return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
&& Alexa.getIntentName(handlerInput.requestEnvelope) === 'ReadEventsIntent';
},
async handle(handlerInput) {
const timeZone = 'Asia/Tokyo';
// インテント作成時追加した「dateTime」スロットに入った値を取得します「11-23」、「12-24」などの日付が入っている。
const requestedDateTime = handlerInput.requestEnvelope.request.intent.slots.dateTime.value;
// 指定された日付と現在の日付との差分を取得
const offsetDay = getDateTimeDiff(timeZone, requestedDateTime);
// 日付を「今日」、「11月23日」などAlexaが自然に読めるようにフォーマットする
const dateText = getDateTimeText(timeZone, requestedDateTime, offsetDay);
// GoogleカレンダーAPIを叩くためのアクセストークン
const accessToken = handlerInput.requestEnvelope.session.user.accessToken;
if (accessToken === undefined) {
// リンクする必要があることをユーザに伝えるためにアカウントリンクカードを返す
const speechText = "スキルを利用するにはグーグルでログインを許可してください";
return handlerInput.responseBuilder
.speak(speechText)
.withLinkAccountCard()
.getResponse();
}
// Google Calendar APIを叩いてイベント取得
const events = await getEvents(accessToken, timeZone, offsetDay);
if (!events.length) {
const speakOutput = "スケジュールがありませんでした。";
return handlerInput.responseBuilder
.speak(speakOutput)
.getResponse();
}
// スケジュールを読める形にフォーマットする
const speakOutput = createSpeackTextforCheckingSchedule(events, timeZone, dateText);
return handlerInput.responseBuilder
.speak(speakOutput)
.getResponse();
}
};
日付のフォーマット
**「dateTime」**スロットで取得した「12-23」や「11-23」といった文字列ではコード上で扱いづらいので、現在からその日付はどの程度離れているのかを表す差分を取得します。
/* 指定された日付と現在の日付との差分を取得 */
const getDateTimeDiff = (timeZone, dateTime) => {
// undefinedのときは0(今日)とする
return dateTime === undefined ? 0 : moment.tz(dateTime, timeZone).diff(moment.tz(timeZone).startOf('days'), 'days');
};
差分にすることで、相対的に日付を扱えるようになるので、フォーマットも楽になります。
dateTexts
では差分をキーとして昨日、今日、明日といった用語を紐付けています。
/* 読み上げるための日付テキストを取得 */
const getDateTimeText = (timeZone, dateTime, offsetDay) => {
const dateTexts = {"-2": "一昨日", "-1": "昨日", "0": "今日", "1": "明日", "2": "明後日"};
// 対応する日付用語があればこれで読み上げる
if (offsetDay in dateTexts) {
return dateTexts[offsetDay];
}
return moment.tz(dateTime, timeZone).format('MM月DD日');
};
また、package.json
に追加したmoment-timezone
を12月01日
といったフォーマットに利用しています。ただのmoment.js
を使わない理由としては、Alexaスキルのデプロイ先がAWS Lambdaで、実行時タイムゾーンがUSとなり意図せず時刻にズレが生じる恐れがあったためです。
moment-timezone
はその名の通りタイムゾーンを使った日付の操作を容易にしてくれるので採用しています。
APIによるイベント取得
package.json
に追加したaxios
を使ってAPIを叩きます。
標準で搭載されているものを使って叩くこともできますが、axiosのほうがクエリストリングをオブジェクトとして渡せる点で綺麗に書けるので採用しています。
ここでもタイムゾーンを気にしてmoment-timezone
を使って日付をAPIに渡せる用にフォーマットしています。また、Google Calendar APIではUTC形式で渡さないとエラーになる点注意が必要です。
ヘッダーには、Authorization
にアクセスリンクによって生成されたAccessTokenを渡しています。
/* カレンダーのイベント情報を取得 */
const getEvents = async (accessToken, timeZone, offsetDay=0) => {
// GETリクエストの作成
const apiURL = 'https://www.googleapis.com/calendar/v3/calendars/primary/events';
const now = moment.tz(timeZone);
// offsetDayが0であれば今日、1であれば明日の日時でオブジェクトを生成する
const dateTime = now.set('day', now.get('day') + offsetDay);
// 指定されたタイムゾーンにおいての0時と23時59分59秒をutc時刻に変換
const timeMin = moment(dateTime.startOf("day").format()).utc().format();
const timeMax = moment(dateTime.endOf("day").format()).utc().format();
// 日付関係なく定期イベントの初回が含まれてしまうので呼ばれないようにする
const singleEvents = true;
const params = {singleEvents, timeMin, timeMax};
const headers = {
Authorization: `Bearer ${accessToken}`
};
// API コール
const events = await axios.get(apiURL, {params, headers})
.then(response => response.data.items);
return events;
};
詳しいリクエストやレスポンス情報を知りたい場合は、こちらの公式ドキュメントをご参照ください。
Alexaが読める形にテキストをフォーマット
APIで受け取ったイベント情報から**「イベントのタイトル」と「開始時刻」**を抜き出し
何時何分からどのようなイベントがあるか書き出しています。
- タイトル:event.summary
- 開始時刻:event.startDateTime
/* カレンダーの予定を読むためのテキストを生成 */
const createSpeackTextforCheckingSchedule = (events, timeZone, dateText) => {
const scheduleText = events.map((event, index) => {
const title = event.summary;
// 終日や開始日時が取得できないイベントは、件名だけ読み上げる
if (!event.start.dateTime) {
return `${index+1}件目は、${title}`;
}
const startDateTime = moment.tz(event.start.dateTime, timeZone);
const hour = startDateTime.get('hour');
const minute = startDateTime.get('minute');
if (minute === 0) {
return `${index+1}件目は、${hour}時からの${event.summary}`
}
return `${index+1}件目は、${hour}時${minute}分からの${event.summary}`
}).join('、');
const speakText = events.length > 0 ? `${dateText}の予定は、${events.length}件あります。${scheduleText}です。` : `${dateText}の予定はありません。`;
return speakText;
}
作成したハンドラーをエクスポート
index.js
のページ下部に書いているエクスポートでHelloWorldIntentHandler
をReadEventsIntentHandler
に置き換えれば作業は終了です。
/* ----------------省略------------------*/
/** index.jsの最下部
* This handler 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,
ReadEventsIntentHandler, // HelloWorldIntentHandlerと入れ替える
HelpIntentHandler,
CancelAndStopIntentHandler,
FallbackIntentHandler,
SessionEndedRequestHandler,
IntentReflectorHandler)
.addErrorHandlers(
ErrorHandler)
.withCustomUserAgent('sample/hello-world/v1.2')
.lambda();
結果
**「テスト」**タブを選択し、動作確認します。
Googleカレンダーには今日と明日にそれぞれ件名「テスト1」と「テスト2」を入れています。
特定のスキルを呼び出すには**「スキル名+を開いて」**と聞けば良いので、今回の場合「サンプルカレンダーを開いて」と入力しています。
インテントで設定したReadEventsIntentの起動トリガーである「今日の予定を教えて」と入力したところ、期待したとおり本日の開始日時と件名「テスト1」が返ってきました。明日の予定も同様に件名「テスト2」と開始日時が期待したとおり返ってきました。
ちなみにクリスマスの予定を聞いて見ましたが、スケジュールは登録されていないようでした・・・バグですかね・・・(違う
おわりに
AlexaスキルとGoogle Calendar APIとの連携方法を紹介しました。
Google OAuth周りの設定は大変でしたが、コード側で独自にOAuth認証を実装しなくてもAlexaスキル開発環境が提供するアカウントリンク機能が使えるので、開発者側の負担を抑えて開発に専念できました。
また、複雑な言語周りの処理を実装しなくても、ユーザから発せられた音声情報に含まれる「今日」や「明日」などのワードをスキル側で日付と判断し取得できるので、処理を柔軟に変えユーザが求める結果を返すことができました。