Alexa と IBM Cloud Functions でアルバイトのシフトを確認しよう

# はじめに

勤務時間がシフトで決まっている業務に携わっている方（アルバイトなども含みます）、自分のシフトの時間管理はどうしているでしょうか？

働く世代にはほぼスマホが1人1台ずつ行き渡っている現在では、カレンダーアプリなどを使って手動で登録している方や、あえて紙の手帳などに記入している方も多いと思います。

この記事では、自分のシフトの予定を Google カレンダーに登録しておき、Amazon Alexa に話しかけることで、特定の日のシフト（開始時刻と終了時刻）を音声で確認できる Alexa スキルを個人使用目的で開発します。バックエンドには IBM Cloud Functions を使用します。Alexa スキルを AWS を使って作る例はすでに多くあると思いますので、IBM Cloud Functions での実装例を紹介してみたいと思います。

# 用意するもの

- Alexa に対応したデバイス (Amazon Echo シリーズなど) または スマホ用の Alexa アプリ
- IBM Cloud アカウント (無料の範囲内で作成できます)
- Google アカウント (Gmail のメールアドレスがあれば OK)
- Amazon アカウント (Amazon.co.jp で買い物したことがあれば OK)
- アルバイトのシフト表や習い事のスケジュール表（全部または一部が Google カレンダーに登録してあるとします）


【注意点】今回作成するアプリ(Alexaスキル)は、自分または家族などに割り当てられたアルバイトのシフトや習い事のスケジュールを管理するための、個人での使用を目的としています。複数人それぞれの Google カレンダーでのスケジュール管理や確認は想定していませんので、あらかじめご了承ください。


# 作成手順

本記事では次のように作成手順を説明していきます。

1. データフローを確認する
1. シフト管理に使う Google カレンダーをサービスアカウントでアクセスできるように共有設定する
1. IBM Cloud Functions で機能を実装する
1. Alexa developer console でスキルを作成し、バックエンドを指定する

そして、最後に Echo デバイスで動作確認をします。

## データフロー

まずは、今回作成するシステムのデータフローを簡略化して説明します。
![qiita-alexa-ibmcloud-functions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147483/d1829a77-52f8-e84d-b2bf-e349e6bc862e.png)

この図において、データの流れは以下のようになります。
1. ユーザーがAlexaスキルを呼び出して、日付を含んで発話する
1. Alexa が日付を解釈して、IBM Cloud Functions エンドポイントへリクエストを送信する
1. IBM Cloud Functions が日付を基に Google Calendar API で検索する
1. Google Calendar API が該当する日付のイベントを返答する
1. IBM Cloud Functions が検索結果を基にメッセージテキストを作成して Alexa へ返答する
1. Alexa がメッセージテキストを読み上げる

## Google カレンダーの予定の表記

シフトに番号がついている場合、予定のタイトルの末尾に `#3` などのようにハッシュサインとシフト番号を入れておくと、シフト番号も読み上げてくれるようにします。

| 予定の項目 | 例 |
|:----------|:------------|
| タイトル     | シフト #2 |
| 日時  | 2022年12月15日 09:30 〜 13:30 |

詳細やその他の項目は読み上げの対象にはなりません。

## Google カレンダーの設定

### カレンダーIDの確認

使用する Google Calendar の ID は、次のように確認します。
1. カレンダーの画面の左にリストされている **マイカレンダー** から、対象のカレンダーを見つける
1. そのカレンダーにマウスを合わせると現れる縦3点リーダー「︙」のオーバーフローメニューから **設定と共有** を選ぶ
1. **カレンダーの設定** 画面で、**カレンダーの統合** セクションまでスクロールしていき、**カレンダーID** を選択してコピーする

このカレンダーIDを、後述する `app.js` の中で `CALENDAR_ID` にセットします。

### サービスアカウントの作成と秘密鍵のダウンロード
[Google Cloud コンソール](https://console.cloud.google.com/)上で Google Calendar API を使うプロジェクトをつくり、サービスアカウントを作成します。

さらに、実際にシフト管理に使用する Google カレンダーの設定にてこのサービスアカウントのメールアドレス(`<サービスアカウント名>@<プロジェクトID>.iam.googleserviceaccount.com`) を共有の相手として追加します。

サービスアカウントの秘密鍵を JSON 形式でエクスポートして保存します。元のファイル名では `<プロジェクトID>_<数字のID>.json` のようになっていますが、以下ではこれを `service-account.json` という名前で参照します。

これらの手順は、Google カレンダーにシフトを入力するアプリの作成方法を紹介した記事の中で確認いただけるようにします。(更新) 12/23 に公開しました。

https://qiita.com/midheartty/items/691e464f5801fafcf5f1

## IBM Cloud Functions での実装

IBM Cloud Functions で実装する機能としては、以下のようになります。

- Alexa スキルで解釈された、日付を含む発話内容[^assistant]を受け取る
- 日付から Google カレンダーを検索して該当する日のシフト内容を取得する
- シフト内容から、開始時刻と終了時刻を含むメッセージを作成する
- 作成したメッセージを、Alexa に送り返す

[^assistant]: IBM Cloud の Watson Assistant 上でも発話内容からの日付の抽出などができますが、今回は Alexa アプリ上の機能で実装します。

IBM Cloud Functions へアップロードする形式としては、Node.js アプリで必要なファイル一式を ZIP で固めたものになります。Web 上のエディタでは作成できない形式です。

アップロードするための ZIP (`myapp.zip`とします) に含まれるファイルの一覧はこちらです。

| ファイル名 | 目的 |
|:----------|:------------|
| app.js     | アプリのメインファイル |
| package.json  | Node.js アプリのファイル |
| service-account.json | サービスアカウント資格情報ファイル |
| node_modules/ | Node.js 依存関係モジュール(ディレクトリ) |

### app.js の内容

コードの内容については、こちらの Web記事 [Amazon Echoでスマートに予定の管理をしよう！](https://developer.cybozu.io/hc/ja/articles/115005869346-Amazon-Echo%E3%81%A7%E3%82%B9%E3%83%9E%E3%83%BC%E3%83%88%E3%81%AB%E4%BA%88%E5%AE%9A%E3%81%AE%E7%AE%A1%E7%90%86%E3%82%92%E3%81%97%E3%82%88%E3%81%86-)を参考にさせていただきました。

```app.js
const Alexa = require('ask-sdk-core');
const { SkillRequestSignatureVerifier, TimestampVerifier } = require('ask-sdk-express-adapter');
const luxon = require("luxon");
const { google } = require('googleapis');
const fs = require('fs').promises;
const path = require('path');

const ALEXA_SKILL_ID = 'amzn1.ask.skill.1234...';
const CALENDAR_ID = '...@group.calendar.google.com';
const TZ = 'Asia/Tokyo';
const SERVICE_ACCOUNT_FILE = 'service-account.json';
const SCOPES = ['https://www.googleapis.com/auth/calendar'];

const calendar = google.calendar('v3');

const authenticate = async () => {
  const keyPath = path.join(__dirname, SERVICE_ACCOUNT_FILE);

  if (!!(await fs.lstat(keyPath))) {
    try {
      const auth = new google.auth.GoogleAuth({
        keyFile: keyPath,
        scopes: SCOPES,
      });
      return await auth.getClient();
    } catch (err) {
      console.error(err);
    }
  }
  return;
}

const listEvents = async (auth, calendar_id, startdate, enddate) => {
  try {
    const response = await calendar.events.list({
      auth: auth,
      calendarId: calendar_id,
      timeMin: startdate,
      timeMax: enddate,
      maxResults: 1000,
      singleEvents: true,
      orderBy: 'startTime',
      timeZone: TZ,
    });
    return response.data.items;
  } catch (err) {
    console.error('listEvents: the API returned an error: ' + err);
    return;
  }
}


const LaunchRequestHandler = {
  canHandle(handlerInput) {
    return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
  },
  handle(handlerInput) {
    const speechText = '私のシフト管理にようこそ。シフト管理はおまかせください。';

    return handlerInput.responseBuilder
      .speak(speechText)
      .reprompt(speechText)
      .withSimpleCard(speechText, speechText)
      .getResponse();
  }
};

const AskRotationHandler = {
  canHandle(handlerInput) {
    return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
    && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AskRotation';
  },
  async handle(handlerInput) {
    const targetDate = Alexa.getSlotValue(handlerInput.requestEnvelope, 'targetDate');
    let events = [];
    let targetDateText = "日付不明";
    if (targetDate) {
      const date = luxon.DateTime.fromISO(targetDate);
      const fromDate = date.minus({hours: 9});
      const toDate = fromDate.plus({days: 1}).minus({seconds:1});
      const fromDateTime =  fromDate.toFormat('yyyy-MM-dd') + 'T' + fromDate.toFormat('HH:mm:ss') + 'Z';
      const toDateTime = toDate.toFormat('yyyy-MM-dd') + 'T' + toDate.toFormat('HH:mm:ss') + 'Z';
      const client = await authenticate();
      events = await listEvents(client, CALENDAR_ID, fromDateTime, toDateTime);
      targetDateText =  date.toFormat('M月d日');  
    }
    let speechText = "";
    let cardTitle = "";
    let cardText = "";
    if (events.length>0) {
      const summary = events[0].summary;
      let timeText = "";
      let startTimeText = "";
      let endTimeText = "";
      if (events[0].start.dateTime) {
        const startTime = events[0].start.dateTime;
        const zone = events[0].start.timeZone;
        const endTime = events[events.length-1].end.dateTime;
        const startTimeValue = luxon.DateTime.fromISO(startTime, {zone:zone});
        const endTimeValue = luxon.DateTime.fromISO(endTime, {zone:zone});
        startTimeText = startTimeValue.toFormat('HH:mm');
        endTimeText = endTimeValue.toFormat('HH:mm');
        timeText = `開始は ${startTimeText}、終了は ${endTimeText} です。`;
      }
      const idx = summary.indexOf("#");
      if (idx>-1) {
        const rotNum = summary.substr(idx+1);
        speechText = `${targetDateText} のシフトは ${rotNum} です。${timeText}`;
        cardText = `${targetDateText}：シフト ${rotNum}`;
      } else {
        speechText = `${targetDateText} のシフトについて。${timeText}`;
        cardText = `${targetDateText}：シフトについて`;
      }
      cardTitle = `${targetDateText} のシフトについて`;
      if (startTimeText) {
        cardText += `\n開始：${startTimeText}\n終了：${endTimeText}`;
      }  
    }
    return handlerInput.responseBuilder
      .speak(speechText)
      .withSimpleCard(cardTitle, cardText)
      .getResponse();    
  }
};

const SessionEndedRequestHandler = {
  canHandle(handlerInput) {
    return Alexa.getRequestType(handlerInput.requestEnvelope) === 'SessionEndedRequest';
  },
  handle(handlerInput) {
    return handlerInput.responseBuilder.getResponse();
  }
};

const ErrorHandler = {
  canHandle() {
    return true;
  },
  handle(handlerInput, error) {
    console.log(`処理されたエラー： ${error.message}`);

    return handlerInput.responseBuilder
      .speak('すみません。コマンドを理解できませんでした。もう一度言ってください。')
      .reprompt('すみません。コマンドを理解できませんでした。もう一度言ってください。')
      .getResponse();
  }
};

const main = async (params) => {
  const skill = Alexa.SkillBuilders.custom()
  .addRequestHandlers(
    LaunchRequestHandler,
    AskRotationHandler,
    SessionEndedRequestHandler,
    )
  .addErrorHandlers(ErrorHandler)
  .create();

  if (params.__ow_headers) {
    const textBody = Buffer.from(params.__ow_body, 'base64').toString('utf-8');
    let requestVerified = false;
    const jsonBody = JSON.parse(textBody);
    try {
      await new SkillRequestSignatureVerifier().verify(textBody, params.__ow_headers);
      await new TimestampVerifier().verify(textBody);
      if (jsonBody.session.application) {
        requestVerified = jsonBody.session.application.applicationId == ALEXA_SKILL_ID;
      }
    } catch (err) {
      console.log(err);
    }

    if (requestVerified) {
      const response = await skill.invoke(jsonBody);
      return {
        headers: {},
        statusCode: 200,
        body: response,
      };
    } else {
      return {
        headers: {},
        statusCode: 400,
        body: 'Bad request',
      };  
    }
  }

  return {
    headers: {},
    statusCode: 204,
    body: 'No content'
  };
};

exports.main = main;
```

`ALEXA_SKILL_ID` と `CALENDAR_ID` は今回はコードの中に埋め込むようにしています。`ALEXA_SKILL_ID` は後ほど Alexa スキルを作成したあとに取得します。

`main` 関数の中で、リクエストの検証をしています。Alexa SDK で用意されている検証メソッドを用いて確認したあと、 Alexa のスキルID が、リクエストに含まれる Application ID と一致するかを検証しています。これにより、作成したスキルからのアクセスしか受け付けないようにできます。

HTTP のリターンコードは3種類返すようにしています。
1. Echo デバイス以外からのアクセス (`curl` コマンドなどでのエンドポイントの存在確認) => 204 "No content"
1. Echo デバイスからのリクエストの内容が正しく処理された場合 => 200 "OK"
1. Echo デバイスからのリクエストであると思われるが、内容が正しく処理できない場合 => 400 "Bad request"

### package.json の内容

```package.json
{
  "name": "myapp-alexabot",
  "version": "0.1.0",
  "description": "MyApp Alexa BOT",
  "main": "app.js",
  "dependencies": {
    "ask-sdk-core": "^2.12.1",
    "ask-sdk-express-adapter": "^2.12.1",
    "ask-sdk-model": "^1.39.0",
    "fs": "0.0.1-security",
    "googleapis": "^105.0.0",
    "luxon": "^3.1.0"
  }
}
```

### その他のファイルの内容

#### service-account.json の内容

```service-account.json
{
  "type": "service_account",
  "project_id": "<your-project-name>",
  "private_key_id": "...",
  "private_key": "-----BEGIN PRIVATE KEY-----\n...",
  "client_email": "<your-service-account>@<your-project-id>.iam.gserviceaccount.com",
  "client_id": "...",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/<your-service-account>%40<your-project-id>.iam.gserviceaccount.com"

}
```

#### node_modules の内容

`package.json` を作成したあと、`npm` コマンドで関連するライブラリを以下のコマンドでダウンロードします。

```
% npm install
```

すると、`node_modules` というディレクトリが作成され、依存するライブラリが格納されます。このフォルダごと ZIP ファイルに組み込みます。

### アップロードするコマンド

まずは、ZIP ファイルを作成します。（コマンド例を示します）
```
% zip -q -r myapp.zip app.js package.json service-account.json node_modules  
```

続いて、IBM Cloud CLI を用いてログインして、IBM Cloud Functions のパッケージ `myapp` を作成します（作成済みの場合は省略）。
```
% ibmcloud login
% ibmcloud fn package create myapp
```

そして、タイプを Web の Raw に指定して、Node.js v16 アプリとして、`create` コマンドでZIP ファイルをアップロードします。
```
% ibmcloud fn action create myapp/alexabot myapp.zip --web raw --kind nodejs:16
```

ファイルを更新したときは、再度 ZIP ファイルを作り直して、`update` コマンドで ZIP ファイルをアップロードします。
```
% ibmcloud fn action update myapp/alexabot myapp.zip --web raw --kind nodejs:16
```

`console.log(...)` などで出力した内容を確認するために、アクション・ログがリアルタイムで流れてくるようにします。

```
% ibmcloud fn activation poll
```

### エンドポイント URL の確認

IBM Cloud コンソール上で、Functions のダッシュボード (https://cloud.ibm.com/functions/) を開き、`myapp/alexabot` の設定画面から「エンドポイント」を開きます。

Web アクションのセクションで、**Web アクションとして有効化** と **未加工HTTP処理** がセットされていることを確認します。さらに、HTTPメソッドの欄が以下のようになっています。

| HTTPメソッド | 認証 | URL |
|:----------|:------------|:----|
| いずれか     | パブリック | (例) https://us-south.functions.appdomain.cloud/api/v1/web/your_namespace/myapp/alexabot |

※この記事では、エンドポイント自体には認証は行いませんので、パッケージ名 `myapp` やアクション名 `alexabot` を適宜変更して、この URL が他者に容易に推測されないように注意してください。しかしながら、データの中身（ペイロード）は、 Alexa からの正式なアクセスであることを確認するコードが入っていますので、URLが推測されても不正なアクセスをしてカレンダーの内容を確認することはできないようにしています。

## Alexa スキルの作成

[Alexa developer console](https://developer.amazon.com/alexa/console) にログインして、Alexa のスキルを作成します。

今回の記事では、申請を目的としたものではなく、個人使用に限定してステータスが「開発中」のままで使うことを前提とします。

こちらの記事を参考にして、「カスタム」スキルで「ユーザー定義のプロビジョニング」を選びます。

https://qiita.com/kurisaka/items/24f5dfe54d718e33c1c2

続いて、上記の記事を参考にして以下のように、`targetDate` (AMAZON.DATE型) のインテントスロットを作成して、サンプル発話に組み込みます。このインテントを `AskRotation`（呼び出し名「シフト管理」） とします。

| 設定項目 | 内容 |
|:----------|:------------|
| 呼び出し > スキルの呼び出し名     | シフト管理 |
| 対話モデル > インテント     | AskRotation |
| 対話モデル > インテント > AskRatation > サンプル発話     | {targetDate} のシフトを教えて |
| 対話モデル > インテント > AskRatation > インテントスロット     | targetDate (AMAZON.DATE) |

「モデルを保存」して「モデルをビルド」してエラーが出ないことを確認します。

認識した発話を IBM Cloud Functions へ送るために、エンドポイントの設定をします。

| 設定項目 | 内容 |
|:----------|:------------|
| エンドポイント | HTTPS - デフォルトの地域 - IBM Cloud Functions `myapp/alexabot` の URL (**エンドポイントURLの確認**参照) 「開発用のエンドポイントには、信頼された証明機関が発行した証明書があります」をセット|

「エンドポイントを保存」をクリックして保存します。

### Alexa スキル ID の確認

IBM Cloud Functions で使う `app.js` の先頭に `ALEXA_SKILL_ID` という変数があります。ここには、作成したスキルの識別子(ID)をセットします。

スキル ID の確認は、[alexa developer console](https://developer.amazon.com/alexa/console/ask) のページで行います。

Alexa スキルの一覧の中から、作成したスキル名「シフト管理」の下にある「カスタム・スキルIDをコピー」の「スキルIDをコピー」の部分をクリックすると、クリップボードにスキルIDがコピーされます。

# Echo デバイスで動作確認

アプリのデプロイが終わりましたので、Echo デバイスで動作確認をしてみます。

近くにある Echo デバイスに向かって、あるいはスマホの Alexa アプリからスキルを呼び出します。

> 自分「アレクサ、シフト管理」

すると、IBM Cloud Functions で `LaunchRequestHandler` でセットした関数が呼ばれ、その中に埋めた起動時メッセージを Alexa が読み上げます。

> Alexa「私のシフト管理にようこそ。シフト管理はおまかせください。」

続いて、日付をいれて問いかけます。

> 自分「きょうのシフトを教えて」
> Alexa「12月15日のシフトは 2 です。開始時刻は 9時30分、終了時刻は13時30分です。」

現時点での Alexa の日付認識では、「きょう」「あした」は認識できるようです。

> 自分「あしたのシフトを教えて」

具体的な日付（年は含みません）を指定すると、未来の日付は今年のものとして、過去の日付は多くの場合来年の日付として認識します。

きょうが12月15日の場合、

> 自分「12月25日のシフトを教えて」

は、同じ年の12月25日として解釈し、

> 自分「12月10日のシフトを教えて」

は、次の年の12月10日として解釈するようです。

以上で動作確認は完了です。

IBM Cloud Functions のアクションは、リクエストがあると起動する仕組みですが、一定時間アクセスがないといったん終了します。したがって、時間を置いてから再度起動すると、最初の立ち上がりに数秒から10秒程度かかることがあります。そのような場合は、起動時メッセージが確認できるまでじっとお待ちください。

また、公開スキルとはしないため、常に「アレクサ、シフト管理」で起動したあとに日付を問いかけるというように、アレクサ呼びかけとセットになる点もご注意ください。

# さいごに

本記事では、Google カレンダーに登録済みのシフトのスケジュールについて Alexa に問い合わせることができるスキルを、IBM Cloud Functions を用いて作成しました。使用頻度にも依りますが、1日に数回起動する程度であれば無料の範囲で実行可能です。

自分のシフトではなくても、ご家族のシフトや、習い事のスケジュールなどをカレンダー共有をすることによってスマホを開かずに確認することができるようになりますので、本記事の手順を参考に、試してみてください。