TypeScript + claspで Google Calendarの更新情報をGoogle Chatに通知するGoogle Apps Scriptを作成する

TypeScript + clasp で Google Calender API を操作するGoogle Apps Script（以下、gas）を作成する。

TypeScript + clasp の環境構築については[過去の記事](https://qiita.com/KShamoji/items/91f0a00fe872b2f8412f)を参照。

## gasプロジェクトを作成する


まずは対象となるgasプロジェクトを作成及びTypeScript用の環境設定を行う

```sh
# 任意のディレクトリへ移動する
cd /path/to/project

# Google Calendar API を操作するプロジェクトを作成する
mkdir calendar-api-sample

# gasプロジェクトを作成する
clasp create --type standalone

# gas用のTypeScript定義をインストール
npm i -S @types/google-apps-script
```

ここまでのフォルダ構成

```shell-session
$ tree -a -L 1
.
├── .clasp.json
├── appsscript.json
├── node_modules
├── package-lock.json
└── package.json
```

## プロジェクトにGoogle Calendar APIを追加する

GCPのプロジェクトIDを使わずに`clasp`上で追加する方法がわからなかったので、gasのコンソール上で追加した後、pullをする。

**サービスの追加**
![スクリーンショット 2022-01-28 17.57.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1265758/53b9e50a-a7ab-861f-939a-8dd8eaaab40e.png)

**ローカルプロジェクトのアップデート(`clasp pull`)**

```sh
clasp pull
Warning: files in subfolder are not accounted for unless you set a '/workspaces/gas-sample/calendar-api-sample/.claspignore' file.
Cloned 1 file.
└─ /workspaces/gas-sample/calendar-api-sample/appsscript.json
```

`appscript.json`にCalenderへのdependenciesが追加されていることを確認する。

![スクリーンショット 2022-01-28 18.11.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1265758/a963fccc-79c0-be3f-f257-64725c8ee94a.png)

※つまりは`appscript.json`のdependnciesに以下の内容を手書きすればgasのコンソール上じゃなくてもできるようです。

```jsonc:appscript.json
  "dependencies": {
    "enabledAdvancedServices": [
      {
        "userSymbol": "Calendar",
        "version": "v3",
        "serviceId": "calendar"
      }
    ]
  }
```

## Google Caldendar APIを使った実装を試してみる

Google Calendarの変更をトリガーにしたアプリケーションを作ってみます。
機能の流れとしては以下を想定しています。

1. Google Calendarからの変更通知を受ける
1. 変更されたカレンダーのイベントを取得する
1. 変更されたカレンダーのイベントの内容をGoogle Chatに送信する

### Google Calendarからの通知を受け取る

まずは変更を受け取るための関数を用意します。

Google Calendarからどういったイベントが通知されるのか、[公式ドキュメント](https://developers.google.com/apps-script/guides/triggers/events#google_calendar_events)から確認します。

上記より、Google CalendarからEventが発生した場合、関数に渡されるEventObjectは以下のプロパティが設定されていることがわかります。

| property name | description |
|-|-|
|authMode	|ScriptApp.AuthMode の値。 <br>例： ```FULL```|
|calendarId	|イベントの更新が発生したカレンダーのID。<br>例： susan@example.com
|triggerUid	|このイベントを生成したトリガーのID。 <br> 例：```4034124084959907503```|

つまりEventから`calendarId`を取得したい場合は以下となります。

```ts:main.ts
interface CalendarUpdatedEvnet {
  authMode: GoogleAppsScript.Script.AuthMode;
  calendarId: string;
  triggerUid: string;
}

function onUpdatedEvent(e: CalendarUpdatedEvnet) {
  console.log(`calendarId: ${e.calendarId}`);
}
```

上記のソースを`clasp push`でgasプロジェクトへUploadしましょう。

```js:main.gs
// Compiled using undefined undefined (TypeScript 4.5.5)
"use strict";
function onUpdatedEvent(e) {
    const calendarId = e.calendarId;
    console.log(`calendarId: ${calendarId}`);
}
```
（`interface`はjsへトランスコンパイルされると消えてしまう悲しいさだめをもったオブジェクトです）

トリガーを作成し、先ほど作った`onUpdatedEvent`を実行する関数として指定します。

![スクリーンショット 2022-01-28 19.18.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1265758/709e2557-87e2-a363-ed45-c551f1a14467.png)

先ほどトリガーのソースとして指定したカレンダーを更新すると、無事トリガーが動作し、ログが表示されました。

![スクリーンショット 2022-01-28 19.21.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1265758/be1d63ba-ee2a-506e-c9fe-c72438c49127.png)

### 変更されたカレンダーの情報を取得する

[公式ドキュメント](https://developers.google.com/apps-script/advanced/calendar#synchronizing_events)を参考に変更されたカレンダーのイベントを取得するスクリプトを書いていきます。

```ts:main.ts
interface CalendarUpdatedEvnet {
  authMode: GoogleAppsScript.Script.AuthMode;
  calendarId: string;
  triggerUid: string;
}

function onUpdatedEvent(e: CalendarUpdatedEvnet) {
  console.log(`calendarId: ${e.calendarId}`);
  getUpdatedEvents(e.calendarId).forEach((event)=>{
    console.log(`title:${event.summary}`)
  })
}

/**
 * 本機能で使用するOptionのみを抜粋している。
 * Optionの全定義についてはlinkを参照。
 * 
 * @link https://developers.google.com/calendar/api/v3/reference/events/list#parameters
 */
class CalendarQueryOptions {
  maxResults?: number;
  syncToken?: string;
  timeMin?: string;
}

/**
 * 指定されたCalendarIdに紐付くイベントを前回取得した時から更新があったイベントのみ取得する。
 * 過去に一度もイベントを取得していない場合、過去30日分を取得する。
 *
 * @param {string} calendarId カレンダーを一意に識別するID
 * @returns {GoogleAppsScript.Calendar.Schema.Event[]} イベントの一覧（最大100件）
 */
function getUpdatedEvents(
  calendarId: string,
): GoogleAppsScript.Calendar.Schema.Event[] {
  const properties = PropertiesService.getUserProperties();
  const key = `syncToken:${calendarId}`;
  const syncToken = properties.getProperty(key);

  let options: CalendarQueryOptions = { maxResults: 100 };
  if (syncToken) {
    options = { ...options, syncToken: syncToken };
  } else {
    options = { ...options, timeMin: getRelativeDate(-30, 0).toISOString() };
  }

  const events = Calendar.Events?.list(calendarId, options);

  if (events?.nextSyncToken) {
    properties.setProperty(key, events?.nextSyncToken);
  }

  return events?.items ? events.items : [];
}

/**
 * Helper function to get a new Date object relative to the current date.
 * @param {number} daysOffset The number of days in the future for the new date.
 * @param {number} hour The hour of the day for the new date, in the time zone
 *     of the script.
 * @return {Date} The new date.
 */
function getRelativeDate(daysOffset: number, hour: number) {
  var date = new Date();
  date.setDate(date.getDate() + daysOffset);
  date.setHours(hour);
  date.setMinutes(0);
  date.setSeconds(0);
  date.setMilliseconds(0);
  return date;
}
```

書き終わったら再度`clasp push`をします。
以下は実際にアップロードされたソースです。

```js:main.gs
// Compiled using undefined undefined (TypeScript 4.5.5)
"use strict";
function onUpdatedEvent(e) {
    console.log(`calendarId: ${e.calendarId}`);
    getUpdatedEvents(e.calendarId).forEach((event) => {
        console.log(`title:${event.summary}`);
    });
}
/**
 * 本機能で使用するOptionのみを抜粋している。
 * Optionの全定義についてはlinkを参照。
 *
 * @link https://developers.google.com/calendar/api/v3/reference/events/list#parameters
 */
class CalendarQueryOptions {
}
/**
 * 指定されたCalendarIdに紐付くイベントを前回取得した時から更新があったイベントのみ取得する。
 * 過去に一度もイベントを取得していない場合、過去30日分を取得する。
 *
 * @param {string} calendarId カレンダーを一意に識別するID
 * @returns {GoogleAppsScript.Calendar.Schema.Event[]} イベントの一覧（最大100件）
 */
function getUpdatedEvents(calendarId) {
    var _a;
    const properties = PropertiesService.getUserProperties();
    const key = `syncToken:${calendarId}`;
    const syncToken = properties.getProperty(key);
    let options = { maxResults: 100 };
    if (syncToken) {
        options = Object.assign(Object.assign({}, options), { syncToken: syncToken });
    }
    else {
        options = Object.assign(Object.assign({}, options), { timeMin: getRelativeDate(-30, 0).toISOString() });
    }
    const events = (_a = Calendar.Events) === null || _a === void 0 ? void 0 : _a.list(calendarId, options);
    if (events === null || events === void 0 ? void 0 : events.nextSyncToken) {
        properties.setProperty(key, events === null || events === void 0 ? void 0 : events.nextSyncToken);
    }
    return (events === null || events === void 0 ? void 0 : events.items) ? events.items : [];
}
/**
 * Helper function to get a new Date object relative to the current date.
 * @param {number} daysOffset The number of days in the future for the new date.
 * @param {number} hour The hour of the day for the new date, in the time zone
 *     of the script.
 * @return {Date} The new date.
 */
function getRelativeDate(daysOffset, hour) {
    var date = new Date();
    date.setDate(date.getDate() + daysOffset);
    date.setHours(hour);
    date.setMinutes(0);
    date.setSeconds(0);
    date.setMilliseconds(0);
    return date;
}
```

トリガーが設定されているCalendarを更新し、イベントがロギングされることを確認します。

![スクリーンショット 2022-01-28 20.30.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1265758/ed6728bb-52c5-7b85-1852-4e4b1fddafb7.png)

無事カレンダーのタイトルが表示されました。

### 変更されたカレンダーのイベントの内容をGoogle Chatに送信する

最後は、完全に蛇足ですが、取得したイベントをGoogle Chatに送信します。
Google ChatはGoogle Workspacesがないと使用できない（はず）です。

[公式サイト](https://developers.google.com/chat/how-tos/webhooks)を参考にコードを書いていきます。
gasにおけるHTTPリクエストについては[こちら](https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app)を使用するようです。

```main.ts
interface CalendarUpdatedEvnet {
  authMode: GoogleAppsScript.Script.AuthMode;
  calendarId: string;
  triggerUid: string;
}

const WEBHOOK_URL = "<WEBHOOK_URL>"

function onUpdatedEvent(e: CalendarUpdatedEvnet) {
  console.log(`calendarId: ${e.calendarId}`);
  getUpdatedEvents(e.calendarId).forEach((event) => {
    let message: string;
    if (event.status === "cancelled") {
      message = `
      イベントが削除されました。
      `
    } else {
      message = `
      イベントが更新されました。\n
      *${event.summary}*
      ==================================================\n
      開始日時：${event.start?.dateTime}\n
      終了日時：${event.end?.dateTime}\n
      説明  ：${event.description}\n
      <${event.htmlLink}|LINK>
      `
    }
    sendMessageToChat(WEBHOOK_URL, message)
  });
}

/**
 * 本機能で使用するOptionのみを抜粋している。
 * Optionの全定義についてはlinkを参照。
 *
 * @link https://developers.google.com/calendar/api/v3/reference/events/list#parameters
 */
class CalendarQueryOptions {
  maxResults?: number;
  syncToken?: string;
  timeMin?: string;
}

/**
 * 指定されたCalendarIdに紐付くイベントを前回取得した時から更新があったイベントのみ取得する。
 * 過去に一度もイベントを取得していない場合、過去30日分を取得する。
 * 
 * @link https://developers.google.com/calendar/api/v3/reference/events#resource
 * 
 * @param {string} calendarId カレンダーを一意に識別するID
 * @returns {GoogleAppsScript.Calendar.Schema.Event[]} イベントの一覧（最大100件）
 */
function getUpdatedEvents(
  calendarId: string
): GoogleAppsScript.Calendar.Schema.Event[] {
  const properties = PropertiesService.getUserProperties();
  const key = `syncToken:${calendarId}`;
  const syncToken = properties.getProperty(key);

  let options: CalendarQueryOptions = { maxResults: 100 };
  if (syncToken) {
    options = { ...options, syncToken: syncToken };
  } else {
    options = { ...options, timeMin: getRelativeDate(-30, 0).toISOString() };
  }

  const events = Calendar.Events?.list(calendarId, options);

  if (events?.nextSyncToken) {
    properties.setProperty(key, events?.nextSyncToken);
  }

  return events?.items ? events.items : [];
}

/**
 * Helper function to get a new Date object relative to the current date.
 * @param {number} daysOffset The number of days in the future for the new date.
 * @param {number} hour The hour of the day for the new date, in the time zone
 *     of the script.
 * @return {Date} The new date.
 */
function getRelativeDate(daysOffset: number, hour: number) {
  var date = new Date();
  date.setDate(date.getDate() + daysOffset);
  date.setHours(hour);
  date.setMinutes(0);
  date.setSeconds(0);
  date.setMilliseconds(0);
  return date;
}

/**
 * 引数として与えられたWebhookURLを元にGoogle Chatへメッセージを送付する
 * 
 * @link https://developers.google.com/chat/api/guides/message-formats/basic
 * @param webHookURL Web Hook URL
 * @param message チャットに送付するメッセージ
 */
function sendMessageToChat(webHookURL: string, message: string): void {
  const params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
    payload: JSON.stringify({
      text: message
    }),
    method: "post",
    contentType: "application/json",
  };
  const response = UrlFetchApp.fetch(webHookURL, params);
  console.log(`repsonse:${response}`);
}
```

こちらも`clasp push`した後、トリガーが紐づくカレンダーを更新することで、Google Chatに通知されるようになります。

![スクリーンショット 2022-01-28 21.22.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1265758/66f43e38-e68f-92a5-d94c-5df9e8a864d6.png)

以上です。