GAS使ってGoogleカレンダーの変更をLINEに通知させた


# 概要
GAS使って家用のGoogleカレンダーの変更を家族のLINEに通知させたときの備忘録です（タイトルまんま）
GAS + Googleカレンダー + LINE messaging APIなんてn番煎じ感強いけど気にしない。

これを最初に作ったのは数か月前なんですけどあえて今整理しようとしたきっかけは、お仕事で似たようなことすることになったからと、ベースにさせてもらったコードをもうちょっと調整したかったので勉強と思い出しがてらです。
（スプラ3のフェス中にGASずっと書き書きしてたのでほとんど参加できなかったことが悔やまれる。ちなみに宇宙人陣営でした。[^1]）
[^1]:普段はサーモンラン勢

お仕事の方は主にPython3ばっか使ってる人なのでjsの作法とかよくわかってないかもですけど大目に見てください。~~Python3であっても大目に見てください。~~

# もろもろ準備
とりあえずさくっとLINE messaging API（以下LINE API）の準備
1. LINE Developersにログイン
 https://developers.line.biz/console/
1. Providersの横のcreateからプロバイダー作成
1. 作ったプロバイダーのページ行って「Create a new channel」
1. できた！！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2497956/5c8c9b34-a021-a1bf-42ed-a0d4f064d13f.png)

つぎはGoogle側の準備。
1. Google Driveの「新規>その他」からGoogle Apps Acriptsを選んで適当な名前でプロジェクトを新規作成。
1. Googleカレンダーにマイカレンダー作成。（デフォルトのやつだけでよければ別に作らなくてもOK）
1. スプレッドシートも適当な名前で作っておく。各マイカレンダーの予定を保持しておくのに必要。
1. 作成したプロジェクトからCalendar APIとSpreadSheet APIを入れておく。作成したGASプロジェクトのエディタ画面の左側にあるサービスの+ボタンから追加。
![サービス.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2497956/0da0f295-b7d3-0c5e-388b-63e1c7685413.jpeg)
1. できた！！！

準備終わり。

# コード書いてく
今回は今後機能を足したりすることも考えて、簡単に追加できるように4つに分割しました。（本当に機能を足すとは言っていない）
それぞれの役割は以下の通り。
* LINE送るやつ
* スプレッドシートいじるやつ
* イベントのエンティティクラス
* メッセージの文章を出力するやつ
* マイカレンダーの予定を取ってきて、作成・変更・削除に応じてメッセージを送るやつ（メイン）

## LINE送るやつ

<details><summary>コード全文</summary>

```javascript:line_messege.gs
const PROPERTY_KEY_LINE_TOKEN = "ここは自分のLINE TOKEN";

class Line {
  // リクエストヘッダを作成するプライベートメソッド
  _headers() {
    return {
      "Content-Type": "application/json; charset=UTF-8",
      "Authorization": "Bearer " + PROPERTY_KEY_LINE_TOKEN,
    };
  }

  // 送信するテキストを含むオブジェクトを作成するプライベートメソッド
  _payload(text) {
    return { "messages": [{ "type": "text", "text": text }] };
  }

  // 全ユーザにテキストを送信するメソッド
  send_text_to_all_user(text) {
    var options = {
      "method": "post",
      "headers": this._headers(),
      "payload": JSON.stringify(this._payload(text))
    };

    UrlFetchApp.fetch("https://api.line.me/v2/bot/message/broadcast", options);
  }
}
```

</details>

`https://api.line.me/v2/bot/message/broadcast`はBOTを友達登録している人全員に送信される。今回はBOTを家族全員に登録させて全員行くようにするのでこれでおｋ。
下みたいな感じで実行するとメッセージが送れる。

```javascript:test.gs
function send_test() {
  var text = "テスト";
  var line = new Line();
  line.send_text_to_all_user(text);
}
```

メッセージが送信されたか確認 [^2]する
![line.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2497956/ad4f053e-cb79-4dcc-43db-646d019dc7c6.jpeg)
[^2]:桜の花びらはこの時期のLINEの仕様

## スプレッドシートいじるやつ

<details><summary>コード全文</summary>

```javascript:spread_sheet.gs
// ここはさっき作ったスプレッドシートのURL
const SPREAD_SHEET_EDIT_URL = "https://docs.google.com/spreadsheets/d/xxxxxxxxxxxxxxxxxxxx/edit";

const FIELDS = ["event_id", "summary", "description", "start", "end", "create", "update"];
const FIELDS_LENGTH = FIELDS.length;

class Sheet {
  constructor(calendar_id) {
    // 名前がcalendar_idのシートを取得
    var book = SpreadsheetApp.openByUrl(SPREAD_SHEET_EDIT_URL);
    this.sheet = book.getSheetByName(calendar_id);
    this.table = [];

    // なければ新規シート作成+名前変更
    if (this.sheet == null) {
      this.sheet = book.insertSheet();
      this.sheet.setName(calendar_id);
    };

    // シートが空の場合はヘッダーにフィールド名を追加
    if (this.sheet.getLastRow() == 0) {
      this._push_table(FIELDS);
      this.update_from_table();
    };

    // 現在のシートをthis.tableにセット
    this._set_sheet_table();
  }

  _set_sheet_table() {
    // 最終行までのデータを取得する
    var last_row = this.sheet.getLastRow();
    var range = this.sheet.getRange("A1:G" + last_row);
    this.table = range.getValues();
  }
   // テーブルに挿入するメソッド
  _push_table(array) {
    this.table.push(array);
  }

  // テーブルから特定のidを持つ予定を取得するメソッド
  get_event(event_id) {
    for (var row in this.table) {
      if (this.table[row][0] == event_id) {
        return this.table[row];
      }
    }
    return null;
  }

  // テーブルから特定のidを持つ予定を削除するメソッド
  delete_event(event_id) {
    var new_table = [];
    var last_row = this.sheet.getLastRow();
    for (var i = 0; i < last_row; i++) {
      if (this.table[i][0] != event_id) {
        new_table.push(this.table[i]);
      }
    }
    this.table = new_table;
  }

  // テーブルから特定のidを持つ予定挿入、すでにあった場合アップデートするメソッド
  insert_or_update_event(event_id, summary, description, start, end, create, update) {
    if (this.get_event(event_id) != null) {
      this.delete_event(event_id);
    }
    this._push_table([event_id, summary, description, "'" + start, "'" + end, create, update]);
  }

  // シートをクリアして現状のtableをセットしスプレッドシートの更新をかけるメソッド
  update_from_table() {
    this.sheet.clearContents();
    var range = this.sheet.getRange(1, 1, this.table.length, this.table[0].length);
    range.setValues(this.table);
    SpreadsheetApp.flush();
  }
}
```

</details>

当然ながらAPIは何度も使うと遅くなっちまうので最初に`_set_sheet_table()`で全体を拾ってきて各メソッドでこねくり回した後に、`update_from_table()`で入力するようにしました。

ミソは`SpreadsheetApp.flush();`。これを入れないとスプレッドシートが即時更新されないらしい。タイミングによっては予定の作成と参照が連続する場合もあるので、今回はなるべく随時更新がかかるようにする。

あと、日時を入力するときセルのデータ型が日付型になってしまい、シートから取得してLINEに送ったときに表示が`Tue Apr 04 2023 00:00:00 GMT+0900 (Japan Standard Time)`みたいな形になっちゃった。
入力時に成形してあるのに取り出してからまた日付型を成形するのも二度手間。一回入力したら以降は文字列として扱いたいので、startとendの頭にクオーテーション`'`を入れさせてもらいました。
全範囲取得して`range.setNumberFormat("@");`でもたぶんできるけどやってないです:upside_down:

## イベントエンティティクラス
<details><summary>コード全文</summary>

```javascript:
class Event {
  constructor(event) {
    if (event) {
      this.exists = true;
      if (event instanceof Array) {
        this.set_from_sheet_row(event);
      } else {
        this.set_from_google_api_event(event);
      }
    } else {
      this.exists = false
    }
  }

  _datetime_to_text(datetime, date) {
    if (datetime) {
      return Utilities.formatDate(new Date(datetime), "JST", "yyyy年M月d日 HH:mm");
    } else {
      return Utilities.formatDate(new Date(date), "JST", "yyyy年M月d日");
    }
  }

  set_from_sheet_row(list) {
    this.status = null;

    this.event_id = list[0];
    this.summary = list[1];
    this.description = list[2];
    this.start = list[3];
    this.end = list[4];
    this.create = list[5];
    this.update = list[6];
  }

  set_from_google_api_event(event) {
    this.status = event.status;

    this.event_id = event.id;
    this.summary = (event.summary) ? event.summary : "";
    this.description = (event.description) ? event.description : "";
    this.start = this._datetime_to_text(event.start.dateTime, event.start.date);
    this.end = this._datetime_to_text(event.end.dateTime, event.end.date);
    this.create = String(event.created);
    this.update = String(event.updated);
  }
}
```

</details>

SpreadSheetからとってきたArray型の予定データと、APIでとってきたエンティティデータをおんなじ感じで使えるようにイベントエンティティクラスを作っておきました。

とりあえずどっちから取得したeventでもこれにぶちこんでインスタンス化しておけばプロパティの参照が同じになって読みやすくなる（はず）。
つまりはメインのコードを見やすくするためだけに生まれてきたクラス。

スプレッドシートには`status`は保持していないのでnullにした（怠惰）


## メッセージの文章を出力するやつ
<details><summary>コード全文</summary>

```javascript:
class Message {
  constructor(calendar_name) {
    this.calendar_name = calendar_name;
  }

  // 古い予定と新しい予定の要素に差を出力するメソッド
  _get_diff_text(old_value, new_value) {
    if (old_value == new_value) {
      return old_value;
    } else {
      return `"${old_value}" → "${new_value}"`;
    }
  }

  // 予定を作成したときの文章
  get_create_text(new_event) {
    return (
      "予定が作成されました。\n\n" +
      "* カレンダー：" + this.calendar_name + "\n" +
      "* タイトル：" + new_event.summary + "\n" +
      "* 内容：" + new_event.description + "\n" +
      "* 開始：" + new_event.start + "\n" +
      "* 終了：" + new_event.end
    )
  }

  // 予定を更新したときの文章
  get_update_text(old_event, new_event) {
    var title = this._get_diff_text(old_event.summary, new_event.summary)
    var description = this._get_diff_text(old_event.description, new_event.description)
    var start = this._get_diff_text(old_event.start, new_event.start)
    var end = this._get_diff_text(old_event.end, new_event.end)
    return (
      "予定が更新されました。\n\n" +
      "* カレンダー：" + this.calendar_name + "\n" +
      "* タイトル：" + title + "\n" +
      "* 内容：" + description + "\n" +
      "* 開始：" + start + "\n" +
      "* 終了：" + end
    )
  }

  // 予定を削除したときの文章
  get_delete_text(old_event) {
    return (
      "予定が削除されました。\n\n" +
      "* カレンダー：" + this.calendar_name + "\n" +
      "* タイトル：" + old_event.summary + "\n" +
      "* 内容：" + old_event.description + "\n" +
      "* 開始：" + old_event.start + "\n" +
      "* 終了：" + old_event.end
    )
  }

  // 予定を削除したときの文章（スプレッドシートに同じIDがなかった場合用）
  get_delete_text_not_in_list() {
    return (
      "予定が削除されました。\n\n" +
      "* カレンダー：" + this.calendar_name
    )
  }
}
```

</details>

インスタンス化するときにマイカレンダーの名前を渡して使う用にしておきました。あとはさっき作ったEventエンティティを渡すと文章作って返すメソッドの集まりです。
`_get_diff_text()`の処理とか単純な if-else だし三項演算子でもいいんだけど俺は三項演算子が嫌いだ（確固たる意志）

## マイカレンダーの予定を取ってきて、作成・変更・削除に応じてメッセージを送るやつ（メイン）
<details><summary>コード全文</summary>

```javascript:
const PROPERTY_SYNC_TOKEN_KEY = "SYNC_TOKEN";

class NoticeCalendarEvent {
  constructor(event) {
    this.calendar_id = event.calendarId;
    this.properties = PropertiesService.getScriptProperties();
    this.sync_token = this._get_sync_token();
  }

  _get_sync_token() {
    var token = this.properties.getProperty(PROPERTY_SYNC_TOKEN_KEY + this.calendar_id); //PropertyからsyncTokenを取得する
    if (!token) {
      var options = { "timeMin": (new Date()).toISOString() };
      token = Calendar.Events.list(this.calendar_id, options).nextSyncToken;
    }
    return token
  }

  _set_event_list_and_next_sync_token() {
    var options = { showDeleted: true, timeZone: "Asia/Tokyo", syncToken: this.sync_token };
    var events = Calendar.Events.list(this.calendar_id, options);
    this.calendar_name = events.summary;
    this.event_list = events.items;
    this.properties.setProperty(PROPERTY_SYNC_TOKEN_KEY + this.calendar_id, events.nextSyncToken);
  }

  _set_apis() {
    this.sheet = new Sheet(this.calendar_id);
    this.message = new Message(this.calendar_name);
    this.line = new Line();
  }

  _get_generate_send_message() {
    var message_list = [];

    //基本的に一個だが、ほぼ同時に二つ以上の予定に更新があったとき複数個取得される可能性があるので一応forでまわす
    for (var idx in this.event_list) {
      var message_text = "";
      var new_event = new Event(this.event_list[idx])
      var old_event = new Event(this.sheet.get_event(new_event.event_id));

      if (old_event.exists) {
        if (new_event.status == "cancelled") {
          //スプレッドシートの方に予定があってかつステータスがキャンセル→予定の削除
          var message_text = this.message.get_delete_text(old_event);
          this.sheet.delete_event(old_event.event_id);
        } else {
          //スプレッドシートの方に予定があってキャンセルじゃない→予定の更新
          message_text = this.message.get_update_text(old_event, new_event);
          this.sheet.insert_or_update_event(
            new_event.event_id,
            new_event.summary,
            new_event.description,
            new_event.start,
            new_event.end,
            new_event.create,
            new_event.update
          );
        }
      } else {
        if (new_event.status == "cancelled") {
          //スプレッドシートの方に予定がなくかつステータスがキャンセル→スプレッドシートの方にない予定の削除
          message_text = this.message.get_delete_text_not_in_list();
        } else {
          //スプレッドシートの方に予定がなくかつキャンセルじゃない→新規作成された予定
          message_text = this.message.get_create_text(new_event);
          this.sheet.insert_or_update_event(
            new_event.event_id,
            new_event.summary,
            new_event.description,
            new_event.start,
            new_event.end,
            new_event.create,
            new_event.update
          );
        }
      }
      if (message_text) {
        message_list.push(message_text);
      }
    }

    this.sheet.update_from_table();
    return message_list.join("\n- - - - - - - - - - - - - - -\n\n");
  }

  main() {
    this._set_event_list_and_next_sync_token()
    this._set_apis()
    var message_text = this._get_generate_send_message()
    this.line.send_text_to_all_user(message_text)
  }
}

function execute(event) {
  ncu = new NoticeCalendarEvent(event)
  ncu.main()
}
```

</details>

1. 最初に次のnextSyncTokenをプロパティに保持しておく。
カレンダーの更新があったときに送られてくる`event`ではどのカレンダーに変更があったかは分かるけど、どの予定が更新・作成・削除されたのかは分からないらしい。
しょうがないので`event.calendarId`を使って予定を取得する必要がある。この取得の時に前回実行時のnextSyncTokenをsyncTokenとして使うと、**前回の取得時以降に変更のあった予定のみ取得できる**。
なので上記のようにイベントリストを取ってくるついでにnextSyncTokenをカレンダーごとに保持しておく。

```javascript
  constructor(event) {
    this.calendar_id = event.calendarId;
    this.properties = PropertiesService.getScriptProperties();
    this.sync_token = this._get_sync_token();
  }

  _get_sync_token() {
    var token = this.properties.getProperty(PROPERTY_SYNC_TOKEN_KEY + this.calendar_id); //PropertyからsyncTokenを取得する
    if (!token) {
      var options = { "timeMin": (new Date()).toISOString() };
      token = Calendar.Events.list(this.calendar_id, options).nextSyncToken;
    }
    return token;
  }

  _set_event_list_and_next_sync_token() {
    var options = { showDeleted: true, timeZone: "Asia/Tokyo", syncToken: this.sync_token };
    var events = Calendar.Events.list(this.calendar_id, options);
    this.calendar_name = events.summary;
    this.event_list = events.items;
    this.properties.setProperty(PROPERTY_SYNC_TOKEN_KEY + this.calendar_id, events.nextSyncToken);
  }
```
<br>

2.今回のメイン部分。条件分岐はコード内のコメントアウト参照。
作成・更新・削除各パターンに合わせて文章を返す。ほとんど先に作ってあるメソッドの組み合わせです。
```javascript:
  _set_apis() {
    this.sheet = new Sheet(this.calendar_id);
    this.message = new Message(this.calendar_name);
    this.line = new Line();
  }

  _get_generate_send_message() {
    var message_list = [];

    //基本的に一個だが、ほぼ同時に二つ以上の予定に更新があったとき複数個取得される可能性があるので一応forでまわす
    for (var idx in this.event_list) {
      var message_text = "";
      var new_event = new Event(this.event_list[idx])
      var old_event = new Event(this.sheet.get_event(new_event.event_id));

      if (old_event.exists) {
        if (new_event.status == "cancelled") {
          //スプレッドシートの方に予定があってかつステータスがキャンセル→予定の削除
          var message_text = this.message.get_delete_text(old_event);
          this.sheet.delete_event(old_event.event_id);
        } else {
          //スプレッドシートの方に予定があってキャンセルじゃない→予定の更新
          message_text = this.message.get_update_text(old_event, new_event);
          this.sheet.insert_or_update_event(
            new_event.event_id,
            new_event.summary,
            new_event.description,
            new_event.start,
            new_event.end,
            new_event.create,
            new_event.update
          );
        }
      } else {
        if (new_event.status == "cancelled") {
          //スプレッドシートの方に予定がなくかつステータスがキャンセル→スプレッドシートの方にない予定の削除
          message_text = this.message.get_delete_text_not_in_list();
        } else {
          //スプレッドシートの方に予定がなくかつキャンセルじゃない→新規作成された予定
          message_text = this.message.get_create_text(new_event);
          this.sheet.insert_or_update_event(
            new_event.event_id,
            new_event.summary,
            new_event.description,
            new_event.start,
            new_event.end,
            new_event.create,
            new_event.update
          );
        }
      }
      if (message_text) {
        message_list.push(message_text);
      }
    }

    this.sheet.update_from_table();
    return message_list.join("\n- - - - - - - - - - - - - - -\n\n");
  }

  main() {
    this._set_event_list_and_next_sync_token()
    this._set_apis()
    var message_text = this._get_generate_send_message()
    this.line.send_text_to_all_user(message_text)
  }
```

# トリガーの設定
そして作ったやつをマイカレンダーが更新される度に動かすようにトリガーを設定する必要がある。トリガーの設定は:point_down:みたいな感じにする。
トリガーへの設定画面へはプロジェクトの一番左側の:alarm_clock:みたいなマーク押すと行けます。
![trigger.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2497956/971396a4-8d68-98f7-df35-81f617c2b223.jpeg)
`カレンダーのオーナーのメールアドレス`は各マイカレンダーのカレンダーIDのこと。デフォルトで作成されているマイカレンダーだけGoogleアカウントの普通のメールアドレスで、追加したマイカレンダーの場合はカレンダーの設定の`カレンダーの統合`ってところで確認する必要あり。

また、**通知を送りたいマイカレンダーごとにトリガーを設定する必要がある**。例えば「家族全員」「A_私用」「B_私用」ってマイカレンダーを作ってたらそれぞれの分のカレンダーIDを持つトリガーを設定する必要がある。

トリガーの設定が出来たらマイカレンダーの予定を更新して見て確認して終わり！お疲れさまでした！

# 注意
設定できるトリガーや実行時間数には上限がある！特にトリガーは1プロジェクト20個までしか作れないので注意。

https://developers.google.com/apps-script/guides/services/quotas?hl=ja


# バグ・やってない事
* スプレッドシートにデータがたまり続けるので近いうちに終了したデータを削除する機能をつけなきゃ。
* 初回予定追加時になんか通知行かないのを直してない。

クソ雑魚なので土日でここまでしかできなかった。スプラ3のフェスと鮭共が俺を呼んでいたせいで……
これらは次時間が取れる時にひっそり直そう。

# 感想
Chat GPTにコードぶち込んで「これバグなさそう？」って聞く→直す。
:point_up_2:これ便利すぎ。
あとQiita初投稿だけど結構書くのﾁｶﾚﾀ……

# 参考にさせてもらった偉大なる先駆者様

https://qiita.com/syakesalmon/items/8a022acced0eb54adfb5

https://qiita.com/jooji/items/b9d6bd0ed15d187b18d8