📆

【GAS】Googleカレンダーに特定の予定が作成されたら自動でゲストを追加して予定を共有する

2022/07/08に公開

カレンダーの共有じゃダメなん?

https://coubic.com

Coubicという予約システムがあります。
これ、一般の利用者が予約を入れると、サービス提供側が使っているGoogleのカレンダーなどに自動で予定追加をしてくれる便利なしくみがあります。

https://officialmag.stores.jp/entry/google-calendar/

会社や組織で共用のGoogleカレンダーを1つ作っておき、それに対して連携を行います。
そのカレンダーを各スタッフが参照すれば複数人でも予約への対応ができますが、Googleカレンダーを持っている組織によっては、ルール上共有ができない(運用上のルールだったりGoogle Workspaceの仕様上の制限だったり)場合があります

そんな場合でも、カレンダー内の1つ1つの予定に対して外部のGoogleカレンダーユーザーをゲストとして追加すれば、その予定を他者(ゲスト追加されたユーザー)と共有できて便利です。
今回はそれを自動化するGASを組んでみます。

トリガーからは変更された予定は取得できない

GASのトリガーの中には、非常に便利なことに
「カレンダーの予定が変更されたら関数を実行する」というものがあります。

が、これで実行された関数の引数として渡されるイベントオブジェクトを読んでみても、そのトリガーが発火するきっかけとなった「変更された予定」は含まれていません。
(変更されたカレンダーのIDなら読み取れる)

Google Calendar APIサービスを利用する

変更された予定を読み取るにはトリガー引数ではなく、Google Calendar APIサービスを用います。以下のあたりのページが詳しいです。

https://teratail.com/questions/372729

https://for-dummies.net/gas-noobs/how-to-use-oncalendar-change-triggers-for-gas/

ちょっとややこしいので要約すると、

  1. 最初に、特定の検索条件であらかじめ予定の一覧を取得。すると、その結果一覧をあらわす「トークン」が得られる。
  2. 2回目以降はトリガーで関数を起動する。そのときに手順1のトークンを用いて予定一覧を取得すると、この2回目と手順1で得た予定一覧のうち変更差分の予定のみが得られる。ここでも関数終了前にトークンを保存しておく。
  3. 以降、トリガー起動するたびに手順2が繰り返される。

実際に組んでみます。

1. Google Calendar APIサービスをGASで利用できるようにする

Image from Gyazo

ソースによってはGCPからAPIをオンにしなければいけないと書いてあるところもあるっぽいですが、当方の環境ではGASエディタから上図のように操作するだけで利用可能になりました。

もしかしたら自分の環境だと元からAPI有効になってたとかあるかも……? 面倒なので未確認です。もし間違ってたらコメントとかでご指摘ください〜

2. 条件を指定して予定を検索

options に検索条件を指定して予定の一覧を読み取ります。ここでは現時刻以降の予定を取得しています。
calendarIdprimaryを指定しておくと自分のアカウントのデフォルトカレンダーが対象になりますが、別のカレンダーを指定したい場合はあらかじめ調べておく必要があります。

let events;
const calendarId = 'primary';
const options = {
  timeMin: (new Date()).toISOString(),
  singleEvents: true,
  //orderBy: 'startTime'
};

// Google Calendar APIを用いて予定の一覧を取得する
try {
  events = Calendar.Events.list(calendarId, options);
} catch (e) {
  console.warn(e);
}

if (events.items && events.items.length === 0) {
  console.info('予定が見つかりませんでした。');
  return;
}

// 取得できた予定の配列
console.log(events.items);

optionsのうち、昇順で取得しようと思って最初はorderByを含めていましたが、これを入れていると後述するトークンが取得できなくなることがわかったので、ここでは注意の意味を込めてコメントアウトにしています。

https://stackoverflow.com/questions/68192744/google-calendar-synctoken-is-missing-in-api-response

3. トークンを保存

一度予定を取得できたら、events.nextSyncTokenに次回の差分取得用のトークンが入っているので、それを次回の関数実行時に使えるようにプロパティに保存しておきます。

const properties = PropertiesService.getUserProperties();
properties.setProperty('nextSyncToken', events.nextSyncToken);

このプロパティは関数実行が終了しても値が保持されます。

4. 次回以降の関数実行時にトークンを使って予定を取得

予定の一覧を取得する際、検索条件ではなく保存済みのトークンをoptions.syncTokenにいれておくと、前回の検索実行時と今回の検索実行時での差分(= 変更された予定のみ・複数の場合があるため配列) が取得できます。

const properties = PropertiesService.getUserProperties();
const syncToken = properties.getProperty('nextSyncToken');
options.syncToken = syncToken;

// Google Calendar APIを用いて予定の一覧を取得する
try {
  events = Calendar.Events.list(calendarId, options);
} catch (e) {
  console.warn(e);
}

// 前回のCalendar.Events.list()実行時との予定一覧のうち、更新されたもののみの一覧が取得できる
console.log(events.items);

5. 実際に予定を変更(ゲストを追加)する

https://for-dummies.net/gas-noobs/how-to-add-guest-to-calendar-events-by-gas/

GASでカレンダーにゲストを追加する方法は調べると色々出てきますが、Calendar APIを使っている場合はやり方が少々異なります。

for (const event of events.items) {
  event.attendees.push({ email: '追加したいユーザーのメールアドレス' });
  try {
    // ここで実際に予定の変更を反映する
    const updatedEvent = Calendar.Events.update(event, calendarId, event.id, {
      sendUpdates: 'all'
    }, {});
    const start = new Date(updatedEvent.start.dateTime || updatedEvent.start.date);
    console.info('%s 開始の予定「%s」に ゲスト "%s" を追加しました。(イベントID: %s)', start.toLocaleString(), updatedEvent.summary, '追加したいユーザーのメールアドレス', updatedEvent.id);
  } catch (e) {
    console.warn('予定の更新に失敗しました:', e);
  }
}

https://developers.google.com/calendar/api/v3/reference/events/update

sendUpdatesの値によって、追加したゲストに対するアクションを変えることができます。

sendUpdates 挙動
all 全てのゲストに通知メールを送信
externalOnly Googleカレンダーでないゲストのみに通知メールを送信
none 通知を送信しない

全部まとめてみる

目的が「特定の予定に対してゲストを追加」だったので、追加したいゲストと各々を追加する条件を個別に指定できるようにして、スクリプト全体を組んでみます。

全コード(長いので折りたたみ)
コード.gs
/**
 * 更新された予定が以下の場合にemailのユーザーをゲストとして追加する
 * ・予定詳細にcondition文字列が含まれている
 * ・ゲスト一覧の中にemailが含まれていない(まだゲストとして追加されていない)
 */
const guests = [
  { condition: 'Aさん担当', email: 'Aさんのメールアドレス' },
  { condition: 'Bさん担当', email: 'Bさんのメールアドレス' },
  { condition: 'Cさん担当', email: 'Cさんのメールアドレス' },
];

// この関数をカレンダー変更トリガーに設定しておく
function onCalendarEdit() {
  // カレンダーIDとゲスト配列
  automatedInvite('primary', guests);
}

function automatedInvite(calendarId, guestsArray) {
  const properties = PropertiesService.getUserProperties();
  const now = new Date();
  const options = { maxResults: 100 };

  // 前回実行時などから持ち越されたnextSyncTokenを用いてカレンダーの差分のみを抽出する
  const syncToken = properties.getProperty('nextSyncToken');
  if (syncToken && syncToken != '') {
    // syncToken有:syncTokenを使って前回syncとの差分イベントのみとってくる
    options.syncToken = syncToken;
    console.info('差分同期を行います: syncToken = %s', syncToken);
  } else {
    // syncToken無:今日以降のイベントを出来る限りとってきてフル同期する
    options.timeMin = now.toISOString();
    options.singleEvents = true;
    //options.orderBy = 'startTime';
    console.info('フル同期を行います: %s 以降のすべての予定', now.toLocaleString());
  }
  
  let events;
  let pageToken;
  let updated = false;
  // 最低1回実行し、pageTokenがある限り繰り返す
  do {
    // 予定一覧を取得(フル同期の場合はこの時間以降・差分同期の場合は更新された予定のみ)
    try {
      options.pageToken = pageToken;
      events = Calendar.Events.list(calendarId, options);
    } catch (e) {
      // nextSyncTokenが不正か期限切れなら再度フル同期する
      if (e.message === 'Sync token is no longer valid, a full sync is required.') {
        properties.deleteProperty('nextSyncToken');
        automatedInvite(calendarId, guestsArray);
        return;
      }
      throw new Error(e.message);
    }
    // これ以降のイベントがない場合(トリガーじゃないときはほとんどの場合でこれが出る)
    if (events.items && events.items.length === 0) {
      console.info('新規予定が見つかりませんでした。全て同期済みです。');
      return;
    }

    // 予定ごとに処理
    for (const event of events.items) {
      // 差分イベントリストの中の削除済み予定または処理されず残っていてundefinedな予定はスキップ
      if (!event || !event.status || event.status == 'cancelled') continue;
      // 新規作成された予定の場合はいくつかのプロパティがundefinedの場合があるのであらかじめ作成しておく
      if (!event.attendees) event.attendees = [];
      if (!event.description) event.description = '';
      // この予定に含まれるゲストのメールアドレスだけの配列をつくる
      const attendeeEmails = (event.attendees.length != 0) ? event.attendees.map(attendee => attendee.email) : [];
      // さらに1つの予定に対してゲスト候補者ごとに処理
      for (const guest of guestsArray) {
        // このゲスト候補者固有の条件文字列が予定詳細に含まれ かつ まだゲストとして追加されていない場合
        if (event.description.indexOf(guest.condition) != -1 && attendeeEmails.indexOf(guest.email) == -1) {
          // 予定更新(ゲストの追加)をする
          event.attendees.push({ email: guest.email });
          // 更新を反映させる
          try {
            const updatedEvent = Calendar.Events.update(event, calendarId, event.id, {
              sendUpdates: 'all'
            }, {});
            updated = true; // 予定が最低1件でも変更(ゲスト追加)されたらtrue
            const start = new Date(updatedEvent.start.dateTime || updatedEvent.start.date);
            console.info('%s 開始の予定「%s」に ゲスト "%s" を追加しました。(イベントID: %s)', start.toLocaleString(), updatedEvent.summary, guest.email, updatedEvent.id);
          } catch (e) {
            console.warn('予定の更新に失敗しました:', e);
          }
        }
      }
    }

    pageToken = events.nextPageToken;
    if (!updated) console.info('予定の更新を検知しましたが、いずれのゲスト追加条件にも該当しなかったため変更されませんでした。(次のページ: %s)', pageToken);
  } while (pageToken);

  // 次回の差分同期用にトークンをプロパティに保存
  if (events.nextSyncToken) properties.setProperty('nextSyncToken', events.nextSyncToken);
}

初回実行はそこそこ時間がかかりますが、2回目以降はトリガー + syncTokenの合わせ技で差分だけを処理するのでだいたい2秒前後で処理が完了します。すっごくエコな感じで気持ちいいです。

追加したいゲストとその条件をスプレッドシートで管理する

スクリプトに紐づいているスプレッドシートに上図のように記入します。

function getGuests() {
  // このスクリプトに紐づいているスプレッドシートを取得
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  // A2から最終行まで2列で取得
  const lastRow = sheet.getRange(2, 2).getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow();
  const range = sheet.getRange(2, 1, lastRow - 1, 2);
  // 2次元配列からオブジェクトの配列に変換して返す
  return range.getValues().map((guest) => {
    const condition = guest[0].trim();
    const email = guest[1].trim();
    if (condition != '' && email != '') return { condition: condition, email: email };
  }).filter(Boolean); // returnされなかった要素はundefinedになるのでそれを排除
}

// この関数をカレンダー変更トリガーに設定しておく
function onCalendarEdit() {
  const guests = getGuests();
  // カレンダーIDとゲスト配列
  automatedInvite('primary', guests);
}

新たにgetGuests()という関数を追加し、onCalendarEdit()を上記のように書き換えてあげると、あとはゲストとその追加条件はスプレッドシートを編集するだけで管理できます。らくちん!

Discussion