【GAS】Googleカレンダーの変更をLINEに通知する

# イントロダクション

## この記事の概要
Googleカレンダーにで予定が登録／更新／削除されたことをLINEに通知する方法をまとめる。

## 背景
Googleカレンダーを家族で共有して、お互いの予定を確認できるようにしている。
ただ、Googleカレンダーは通知機能が非常にしょぼく、予定が新規登録されたとき、カレンダーのオーナー宛にメール通知するくらいしかできない（オーナーが登録した予定はメンバーには通知されない）。IFTTTと連携することでLINEに通知することはできるが、予定が「登録」されたときのみで更新や削除は通知されないので、物足りない。

Google Calendar APIを叩いてJavaでエンドポイントをつくろうと思ったが、たかだかカレンダーの変更通知のために重厚長大になりすぎる。。。と思ってたら、カレンダーの変更をトリガーにGASを起動できると知ったので挑戦する。

## ざっくりアーキテクチャ

![プレゼンテーション1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/bb34dd64-4bb9-932f-00c3-04a0397eb726.png)

## 開発環境
TypeScriptでコーディングし、ClaspでGASへデプロイを実施している。
- Windows 11
- yarn (v1.22.19)

- clasp (v2.3.0)

# 0. コード全文
いきなり完成形。細かい解説は後述する。

```javascript:application.gs
// Compiled using ts2gas 3.6.4 (TypeScript 4.2.2)
const PROPERTY_KEY_LINE_TOKEN = 'LINE_TOKEN';
const PROPERTY_KEY_SLACK_WEBHOOK_ENDPOINT = 'SLACK_WEBHOOK_ENDPOINT';
const ENDPOINT_LINE_NOTIFY_API = 'https://notify-api.line.me/api/notify';
const PROPERTIES = PropertiesService.getScriptProperties();
function onUpdatedEvent(event) {
    console.time('onUpdatedEvent');
    console.log(`Updated Calendar. calendarId: ${event.calendarId}`);
    try {
        getUpdatedEvents(event.calendarId).forEach((e) => {
            let message;
            if (e.status === "cancelled") {
                message = `\nGoogleカレンダーの予定が削除されました。\n==========\nタイトル：${e.summary}`;
            }
            else {
                let startDateTime;
                let endDateTime;
                let location;
                startDateTime = new Date(e.start.dateTime).toLocaleString("ja-JP", { timeZone: e.start.timeZone });
                endDateTime = new Date(e.end.dateTime).toLocaleString("ja-JP", { timeZone: e.end.timeZone });
                location = e.location == undefined ? "" : e.location;
                message = `\nGoogleカレンダーの予定が更新されました。\n==========\nタイトル：${e.summary}\n開始日時：${startDateTime}\n終了日時：${endDateTime}\n場所      ：${location}`;
            }
            notifyLINE(message);
        });
    }
    catch (e) {
        console.error(e);
        var slackOptions = {
            method: 'post',
            payload: JSON.stringify({ 'username': 'google-calendar-watchdog', 'text': 'カレンダー変更通知処理中にエラーが発生しました。<https://script.google.com/home/projects/1VE5tPlGhiNWUOsJje9HYVOjX4BjvK-VLx5_8-LsV7A2StRMUsu3qXWuM/executions|[ログ]>\nERROR=>' + e.message }),
            muteHttpExceptions: true
        };
        callExternalAPI(PROPERTIES.getProperty(PROPERTY_KEY_SLACK_WEBHOOK_ENDPOINT), slackOptions);
        throw e;
    }
    console.timeEnd('onUpdatedEvent');
}
class CalendarQueryOptions {
}
function getUpdatedEvents(calendarId) {
    var _a;
    console.time('getUpdatedEvents');
    const key = `syncToken: ${calendarId}`;
    const syncToken = PROPERTIES.getProperty(key);
    let options = { maxResults: 100, showDeleted: true };
    if (syncToken) {
        options = { ...options, syncToken: syncToken };
    }
    else {
        options = { ...options, timeMin: getRelativeDate(-10, 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);
    }
    console.timeEnd('getUpdatedEvents');
    return (events === null || events === void 0 ? void 0 : events.items) ? events.items : [];
}
function notifyLINE(message) {
    console.time('notifyLINE');
    var token = PROPERTIES.getProperty(PROPERTY_KEY_LINE_TOKEN);
    var options = {
        method: 'post',
        payload: 'message=' + message,
        headers: { 'Authorization': 'Bearer ' + token },
        muteHttpExceptions: true
    };
    callExternalAPI(ENDPOINT_LINE_NOTIFY_API, options);
    console.timeEnd('notifyLINE');
}
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;
}
function callExternalAPI(endpoint, options) {
    var response = UrlFetchApp.fetch(endpoint, options);
    return response;
}
```

# 1. トリガーを設定する
トリガーの設定はこのようにする。

![画像1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/e80f0d48-a442-9de2-c0ed-051be77eea93.png)

「カレンダーのオーナーのメールアドレス」とあるが、要するに通知対象のカレンダーIDを入力する。カレンダーIDの確認方法は下記を参考にする。

[GASでGoogleカレンダーに予定を追加する](https://qiita.com/jooji/items/b9d6bd0ed15d187b18d8)

これでカレンダーに予定が登録などされると、```onUpdatedEvent```関数がキックされるようになる。

# 2. コードの解説
## ```onUpdatedEvent```関数の引数

```javascript
function onUpdatedEvent(event) {
    ...
}
```

引数```event```にはカレンダー変更イベント情報が含まれているが、「どのカレンダーが変更されたか」しかわからない。どの予定が登録／更新／削除されたのかは、```event```から取得したカレンダーIDを用いてGoogle Calendar APIから予定を取得することになる。

## SyncToken

```javascript
function getUpdatedEvents(calendarId) {
    var _a;
    ...
    const key = `syncToken: ${calendarId}`;
    const syncToken = PROPERTIES.getProperty(key);

    let options = { maxResults: 100, showDeleted: true };
    if (syncToken) {
        // syncTokenがプロパティから取得できたら差分の予定一覧を取得
        options = { ...options, syncToken: syncToken };
    } else {
        // syncTokenがプロパティから取得できなかったら過去10日ぶんの予定一覧を取得
        options = { ...options, timeMin: getRelativeDate(-10, 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) {
        // 取得したsyncTokenはプロパティへ保存しておく
        PROPERTIES.setProperty(key, events === null || events === void 0 ? void 0 : events.nextSyncToken);
    }
    ...
}
```

前述のとおり、カレンダー変更イベントから変更された予定を直接取得することはできないので、カレンダー変更イベントを受けたらGoogle Calendar APIを呼び出して、予定の一覧を取得することになる。
ただ、毎回全予定を取得して更新日時を見るのでは効率が悪いのでSyncTokenを使う。SyncTokenは予定一覧のしおりの役割をしており、前回取得してからの差分（つまり、更新された予定一覧）を取得できる。

最新のSyncTokenはプロパティから取得するが、初めての起動時はプロパティから取得できないため、全予定一覧を取得することで、そこからSyncTokenを取り出すことができる。ここで取得する全予定一覧はあくまでSyncTokenを取得するためのものなので、全量を取得する必要はなく、```timeMin```を指定して件数を絞り込んで性能劣化を避ける。

取得したsyncTokenはGASのプロパティに保存しておくことで、二回目以降の起動時はプロパティから取得できるようになる。

## 予定の更新 or 削除の判別

```javascript
function onUpdatedEvent(event) {
    ...
    try {
        getUpdatedEvents(event.calendarId).forEach((e) => {
            let message;
            if (e.status === "cancelled") {
                // ステータスがcancelledなら「予定の削除」
                message = `\nGoogleカレンダーの予定が削除されました。\n==========\nタイトル：${e.summary}`;
            } else {
                // ステータスがcancelled以外なら「予定の登録、更新」
                ...
                message = `\nGoogleカレンダーの予定が更新されました。\n==========\nタイトル：${e.summary}\n開始日時：${startDateTime}\n終了日時：${endDateTime}\n場所      ：${location}`;
            }
            ...
```

イベントステータスを確認して、更新／削除のいずれなのか判別するロジックは上記のとおり。

### 予定の「削除」
更新後の予定の```status```が```cancelled```であれば、予定が「削除」されたとわかる。
デフォルトオプションでは削除された時点で予定のタイトルなどは取得できなくなってしまうため、イベント一覧を取得する際に```showDeleted```オプションにTRUEを指定することで削除済みイベントの詳細情報も含めて返却してもらえる。

```javascript
function getUpdatedEvents(calendarId) {
    ...
    let options = { maxResults: 100, showDeleted: true };
    ...
}
```

## LINEに通知する

```javascript
function notifyLINE(message) {
    ...
    var token = PROPERTIES.getProperty(PROPERTY_KEY_LINE_TOKEN);
    var options = {
        method: 'post',
        payload: 'message=' + message,
        headers: { 'Authorization': 'Bearer ' + token },
        muteHttpExceptions: true
    };
    callExternalAPI(ENDPOINT_LINE_NOTIFY_API, options);
    ...
}
```

```javascript
function callExternalAPI(endpoint, options) {
    var response = UrlFetchApp.fetch(endpoint, options);
    return response;
}
```

通知メッセージを組み立てたら、LINE Notify APIを使ってLINEへ通知を行なう。LINE Notify APIの使い方は下記記事を参考にした。

[Google Apps ScriptからLINE NotifyでLINEにメッセージを送る](https://qiita.com/tadaken3/items/5f916a12587e42ece814)

# まとめ
GASを使うことでサーバレス／省コードで実現できた。

[![syake-salmon/google-calendar-watchdog - GitHub](https://gh-card.dev/repos/syake-salmon/google-calendar-watchdog.svg?fullname=)](https://github.com/syake-salmon/google-calendar-watchdog)

# 参考文献
* [GASでGoogleカレンダーに予定を追加する](https://qiita.com/jooji/items/b9d6bd0ed15d187b18d8) by @jooji 
* [Google Apps ScriptからLINE NotifyでLINEにメッセージを送る](https://qiita.com/tadaken3/items/5f916a12587e42ece814)
 by @tadaken3