Googleカレンダーに予定を追加・削除・変更したタイミングでその内容をLINE・Slackに通知させる

## はじめに

最近、家族との共有用にGoogleカレンダーを使い始めました。
以前まで使用していたアプリでは、予定の追加や変更があるたびに通知が飛んでいたのですが、Googleカレンダーだとそれができませんでした。
そこで、GASをこねくり回して無理やり通知機能を作ってみました。

通知イメージは次のような感じです。
予定の変更時に、変更前と変更後を出せるのは便利かな～と思っています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/51d24f80-5e23-75de-2c4d-59842d99efd6.png)



# 概要

1. Googleカレンダーでの予定追加・削除・変更をトリガーとしてGASを起動
2. 一定期間内の予定をすべて取得し、前回実行時にスプレッドシートに保存しておいたものと比較する
3. 今回取得した予定の数が前回実行時より多い場合は追加されたと判断
4. 今回取得した予定の数が前回実行時より少ない場合は削除されたと判断
5. 今回取得した予定の数が前回実行時と等しい場合は変更されたと判断
6. 追加・削除・変更された予定のタイトル、開始日時、終了日時、詳細情報を取得
7. 取得した内容をLINEまたはSlackに通知

手順3～5がこねくり回しポイントです。

# 注意点

- 通知先にLINEとSlackを使用しています
- ※今回LINE通知のために使用したLineNotifyは[2025年3月にサービス終了するみたいです](https://developers.line.biz/ja/news/2024/10/07/line-notify-will-be-discontinued/)
- 同時に複数回予定の編集を行った場合に、通知がうまく飛ばなかったりします。十秒程度感覚が空いていれば動くため、あくまで私用の範囲であれば問題ないと思います
- 現時点から過去２か月～１年先の予定を検知の対象としています

# 前準備

## Lineの通知設定

下記を参考にしました。
[スプレッドシートからLineへ通知を送る](https://qiita.com/tky529/items/29ea0908ac25249ba7e0)

## Slackの通知設定

下記を参考にしました。
[Incoming WebhookをつかってGASからSlackにメッセージを送信する方法](https://qiita.com/kakakaori830/items/f3c1a1539b875d933b7a)

## スプレッドシートの準備

下記のようなスプレッドシートを作成。2行目には適当なデータを入れておいてください。
シート名はカレンダーIDにする。[カレンダーIDの取得方法はこちらを参照](https://qiita.com/mikeneko_t98/items/60e264941492d0b44fe5)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/49f4f235-81ed-c968-ede7-70b2b8cb5918.png)

作成したスプレッドシートから、「拡張機能」＞「App Script」でGASを作成します。
最終的な構成は下図の通りです

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/80dd56c9-8bf7-7ae6-da47-026b8617da38.png)


## スプレッドシートの初期化

カレンダーは複数設定できるようにしました。
カレンダーIDは先ほど取得したものを、カレンダー名は通知時に分かりやすいように名前をつけてください。

```js:init.gs
const calendarIds = ["カレンダーID1",
                    "カレンダーID2",
                    "カレンダーID3"]

const calendarNames = {"カレンダーID1":"カレンダー名1",
                        "カレンダーID2":"カレンダー名2",
                        "カレンダーID3":"カレンダー名3"};


function init() {
  for (let i = 0; i < calendars.length; i++) {
    let myCalendar = CalendarApp.getCalendarById(calendarIds[i]);
    
    // 現在日時を取得
    let today = new Date();
    
    // 現在から２か月前の日時を取得
    let startDate = new Date(today);
    startDate.setMonth(today.getMonth() - 2);
    
    // 現在から１年後の日時を取得
    let endDate = new Date(today);
    endDate.setFullYear(today.getFullYear() + 1);

    // ２か月前から１年後までの期間の予定を取得
    events = myCalendar.getEvents(startDate, endDate);

    // スプレッドシートを初期化
    clearValues(calendarIds[i]);
    
    // 予定をスプレッドシートに転記
    setEvents(events, calendarIds[i]);
  }
}

```

## 初期化処理を実行

init()メソッドを指定して実行。シートの２行目にデータが入っていないとエラーになるので注意。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/9f8b9478-d624-38bb-4a53-67eaa19beb17.png)

実行するとシートに予定の情報が一覧で追加されます。（idとtitleは念のため隠しています）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/dc99fca2-cd3d-9a65-f8bb-5a75d53e9b19.png)


# 通知処理の実装

コード.gsは名前を変えるのが面倒なためそのままです。

```js:コード.gs
const dateFormat = 'yyyy/MM/dd HH:mm';

function myFunction(e) {
  const lock = LockService.getScriptLock()
  if(!lock.tryLock(60000)) {
    return
  }
  const myCalendar = CalendarApp.getCalendarById(e.calendarId);
  
  // 現在日時を取得
  let today = new Date();
  
  // 現在から２か月前の日時を取得
  let startDate = new Date(today);
  startDate.setMonth(today.getMonth() - 2);

  // 現在から１年後の日時を取得
  let endDate = new Date(today);
  endDate.setFullYear(today.getFullYear() + 1);

  // ２か月前から１年後までの期間の予定を取得
  events = myCalendar.getEvents(startDate, endDate);

  // スプレッドシートから前回実行時の予定を取得
  var arry = getEvents(e.calendarId);

  if (arry.length > events.length) {
    // 予定削除
    forDeleteEvent(arry, events, e.calendarId);
  } else if (arry.length < events.length) {
    // 予定追加
    forAddEvent(arry, events, e.calendarId);
  } else {
    // 予定変更
    forUpdateEvent(arry, events, e.calendarId);
  }

  // スプレッドシートを初期化
  clearValues(e.calendarId);
  
  // 予定をスプレッドシートに転記
  setEvents(events, e.calendarId);

}


// 前回実行時の予定を取得
function getEvents(calendarId) {

  const sheet = SpreadsheetApp.getActive().getSheetByName(calendarId);
  const lastRow = sheet.getLastRow();
  return sheet.getRange(2,1, lastRow-1,5).getValues();
}

// 予定をスプレッドシートに転記
function setEvents(events, calendarId) {
  // 予定の個数を取得
  eventNum = events.length;

  // シートを取得
  const sheet = SpreadsheetApp.getActive().getSheetByName(calendarId);

  // 各予定をスプレッドシートに転記していく
  for (let i = 0; i < eventNum; i++) {
    var arry = [events[i].getId(), events[i].getTitle(), events[i].getStartTime(), events[i].getEndTime(), events[i].getDescription()];
    sheet.appendRow(arry);
  }
}

// データ削除
function clearValues(calendarId) {

 const sheet = SpreadsheetApp.getActive().getSheetByName(calendarId);
 const lastRow = sheet.getLastRow();
 
 sheet.getRange(2,1, lastRow-1,5).clearContent();
}


// 予定追加の場合
function forAddEvent(arry, events, calendarId) {
  let message = "";
  for (let i = 0; i < arry.length; i++) {
    if (arry[i][0] != events[i].getId()) {
      message += "予定追加";
      message += "\n" + calendarNames[calendarId];
      message += "\nタイトル：" + events[i].getTitle();
      message += "\n開始日時：" + Utilities.formatDate(events[i].getStartTime(), 'JST', dateFormat);
      message += "\n終了日時：" + Utilities.formatDate(events[i].getEndTime(), 'JST', dateFormat);
      message += "\n詳細：" + events[i].getDescription();
      break;
    }
  }
  notifyEvents(message);
  sendSlack(message);
}

// 予定削除の場合
function forDeleteEvent(arry, events, calendarId) {
  let message = "";
  for (let i = 0; i < events.length; i++) {
    if (arry[i][0] != events[i].getId()) {
      message += "予定削除";
      message += "\n" + calendarNames[calendarId]
      message += "\nタイトル：" + arry[i][1];
      message += "\n開始日時：" + Utilities.formatDate(arry[i][2], 'JST', dateFormat);
      message += "\n終了日時：" + Utilities.formatDate(arry[i][3], 'JST', dateFormat);
      message += "\n詳細：" + arry[i][4];
      break;
    }
  }
  notifyEvents(message);
  sendSlack(message);
}

// 予定変更の場合
function forUpdateEvent(arry, events, calendarId) {
  var updatedNum = -1;
  for (let i = 0; i < events.length; i++) {
    // タイトル
    if (arry[i][1] != events[i].getTitle()) {
      updatedNum = i;
      break;
    }
    // 開始日時
    if (Utilities.formatDate(arry[i][2], 'JST', dateFormat) != Utilities.formatDate(events[i].getStartTime(), 'JST', dateFormat)) {
      updatedNum = i;
      break;
    }
    // 終了日時
    if (Utilities.formatDate(arry[i][3], 'JST', dateFormat) != Utilities.formatDate(events[i].getEndTime(), 'JST', dateFormat)) {
      updatedNum = i;
      break;
    }
    // 詳細
    if (arry[i][4] != events[i].getDescription()) {
      updatedNum = i;
      break;
    }
  }

  if (updatedNum >= 0) {
    let message = "";

    message += "予定変更";
    message += "\n" + calendarNames[calendarId];
    message += "\n変更前";
    message += "\nタイトル：" + arry[updatedNum][1];
    message += "\n開始日時：" + Utilities.formatDate(arry[updatedNum][2], 'JST', dateFormat);
    message += "\n終了日時：" + Utilities.formatDate(arry[updatedNum][3], 'JST', dateFormat);
    message += "\n詳細：" + arry[updatedNum][4];

    message += "\n\n変更後";
    message += "\nタイトル：" + events[updatedNum].getTitle();
    message += "\n開始日時：" + Utilities.formatDate(events[updatedNum].getStartTime(), 'JST', dateFormat);
    message += "\n終了日時：" + Utilities.formatDate(events[updatedNum].getEndTime(), 'JST', dateFormat);
    message += "\n詳細：" + events[updatedNum].getDescription();

    notifyEvents(message);
    sendSlack(message);
  }
}

```

通知部分のみ切り出しました。

```js:notify.js
const lineToken = PropertiesService.getScriptProperties().getProperty('lineToken');
const lineNotifyApi = 'https://notify-api.line.me/api/notify';

const slackToken = PropertiesService.getScriptProperties().getProperty('slackToken');

// LINE通知
function notifyEvents(message) {
    const options =
   {
      "method"  : "post",
      "payload" : {"message": message},
      "headers" : {"Authorization":"Bearer " + lineToken}
   };

   UrlFetchApp.fetch(lineNotifyApi, options);
}

// Slack通知
function sendSlack(message) {
  
  const payload = {
      "text": "<!channel> " + message,
  };
 
  const options = {
      "method" : "post",
      "contentType" : "application/json",
      "payload" : JSON.stringify(payload)
  };
 
  UrlFetchApp.fetch(slackToken, options);
}


```

# トリガーの追加

GoogleカレンダーをGASのトリガーにする方法については下記を参照
[【GAS】Googleカレンダー編集時に発動するトリガーを使いこなす](https://for-dummies.net/gas-noobs/how-to-use-oncalendar-change-triggers-for-gas/)

# 動かしてみる

## Googleカレンダーに予定を追加

下図の通り予定を作成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/73ef47bc-ecf5-79bb-d743-2954d859e6cd.png)


### LINEへの通知

![タイトルなし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/632eb20b-78ce-1aa0-ae9b-f3b6466be479.png)


### Slackへの通知

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/67d0a7a3-829d-1ace-0310-1f4a9ce31af8.png)


## Googleカレンダーの予定変更

先ほど追加した予定を下図の通り変更

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/f7d8416d-c452-543e-174d-d978d15ed77c.png)


### LINEへの通知

![タイトルなし2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/52fe32f5-6b32-624c-6ce0-28f0468e9760.png)


### Slackへの通知

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/bc5d9336-777d-62bd-5d1f-55b0f9299e7f.png)


## Googleカレンダーの予定を削除

テスト用の予定を削除

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/30d6faef-6f5a-495c-a6cb-b9f4c47bb369.png)


### LINEへの通知

![タイトルなし3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/04edb4de-5a24-87fb-589a-4bb43b2c4bf1.png)


### Slackへの通知

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628013/a261ab48-b1ad-d609-5141-41a28b4f7758.png)


# 所感

かなり強引な気もしますが、シンプルな作りになっている気もします。
注意点にも書いた通り、ほぼ同時に予定の追加等が行われた場合に通知がうまく飛ばなかったりしますが、私用の範囲であれば問題ないはずです。