【完成版】Googleカレンダーの条件に合う予定が終了したらメンション付きで概要を通知したい(IFTTT + GAS + Slack)

はじめに

知人から以下のような相談を受けました。

Googleカレンダーの決まった予定が終わったタイミングで、その予定の詳細を予定参加者にSlackで通知したいが、簡単にできる方法を知らないか?

IFTTTとSlackの連携や簡単なGASなら知識はあるようでしたが、既存サービスの連携だけでは簡単には実現できなさそうでした。
そこで、ブログのネタを探してもいたので、自分で作ってみることにしました。

前回の続きのようなものです。 ohshige.hatenablog.com

仕様と方針

微妙に改変していますが、具体的な仕様は以下の通りです。

Googleカレンダーの詳細(「説明を追加」の欄)に「要確認」が含まれている予定が終了したタイミングで、リマインダーのために、その予定のゲストへのメンション付きで詳細文全体をslackへ投稿する。

難しいことはしたくないので、予定終了タイミングの検知はIFTTT、Slack通知のための諸々の調整はGASでまとめて行うことにしました。

IFTTTには、「Googleカレンダーの予定のうち特定のキーワードを含むような予定が終了するタイミング」というトリガーがあるので、これを使います。「Google Calendar」→「New event from search ends」です。
ただ、これには問題があり、受け取れる情報が「タイトル」「詳細」「場所」「開始日時」「終了日時」「イベントURL」だけで、メンションを付けるために必要なゲストの情報を取ることができません。
なので、GAS側でGoogleカレンダーと連携してゲスト情報を取得する必要があります。
それさえできれば、最終的には文章を組み立てて、SlackへAPI経由で投稿するだけです。

メンション無しでSlackへ投稿

まずはメンション無しでSlackへ投稿する部分を作り、その後でメンションも含めて投稿できるように修正することにします。

IFTTT

まず、IFTTTの「New Applet」からAppletを作ります。

「if this」は「Google Calendar」を選び、トリガーは「New event from search ends」にします。
適切な自分のGoogleカレンダーを選択し、キーワードとして「要確認」を設定します。

「then that」は「Webhooks」を選び、トリガーは(1つしか無いものの)「Make a web request」にします。
そして、各項目を以下のように設定します。
タイトルと詳細はBodyとして&連結でエスケープして送信します。

項目 内容
URL GASの公開URL
Method POST
Content Type application/x-www-form-url
Body <<<{{Title}}>>>&<<<{Description}>>>

GAS

以下のように書いてSlackへ通知します。

function doPost(e) {
  var params = e.postData.contents.split('&');
  var title = decodeURIComponent(params[0].replace(/\+/g, ' '));
  var description = decodeURIComponent(params[1].replace(/\+/g, ' '));
  notifyToSlack(title, description);
}

function notifyToSlack(title, description) {
  var message = '「' + title + '」の予定が終わりました。\n以下をご確認下さい。\n---\n' + description;
  postSlack(message);
}

function postSlack(message) {
  var data = {
    'text': message
  };
  var options = {
    'method' : 'post',
    'contentType': 'application/json',
    'payload' : JSON.stringify(data)
  };
  UrlFetchApp.fetch('https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', options);
}

試してみる

「要確認」というキーワードを含めた上で適当に予定を作り、その予定が終わるのを待つと、Slackへ通知が来ます。

メンション付きでSlackへ投稿

メンション無しでの投稿はできたので、続いてメンション付きでやってみます。

メンションを付けるためにはその予定のゲスト情報を取得する必要があります。
IFTTTからはゲスト情報を送信することができないので、GASでなんとかしてゲスト情報を取得する必要があります。
それさえできれば、その情報を使ってSlackに送信するだけです。

GASでGoogleカレンダーの情報を取得

リファレンスによるとGoogleカレンダーの1つ1つの予定はCalendarEventというクラスに対応しているようです。 developers.google.com そして、そのメソッドとしてgetGuestList()というものがあり、これを使えばゲスト情報を取得できます。

というわけで、IFTTTから送れる情報とGASの何らかのAPIを使ってCalenderEventを定める必要があります。
developers.google.com

GASで使えるAPIとして、Calendar.getEventById(iCalId)というものがあります。
iCalIDなる識別子を渡すことができれば良さそうですが、IFTTTから送ることのできる情報にはないので無理そうです。
IFTTTからはイベントURLを送ることはできますが、このURLに含まれる識別子とは別のものなのでパースして云々という方法ではできません。

というわけで、Calendar.getEvents(startTime, endTime, options)を使います。
これは、開始日時と終了日時から予定を検索するものです。
さらに、オプションとしてキーワード検索も含めることができます。

以下のようにして、カレンダーの予定を探し出します。

function findCalendarEvent(startTime, endTime, keyword) {
  var options = {
    'max': 1,
    'search': keyword
  }
  var calEventList = CalendarApp.getDefaultCalendar().getEvents(startTime, endTime, options);

  if (calEventList.length === 0) {
    return null;
  }

  return calEventList[0];
}

optionsに含まれるmaxは最大1件のみを返すという意味です。
同じ時間帯に「要確認」の複数の予定が入っていた場合は困りますが、もともとの仕様を考慮すると基本的に起こりえないこととして無視します。

これでカレンダーの予定が特定できたのであとはゲストを取得するだけで済みそうですが、そうでもありません。

上記のfindCalendarEvent(startTime, endTime, keyword)startTimeendTimeはDateオブジェクトを想定しています。
IFTTTから送られてくる日時のフォーマットはJune 24, 2019 at 07:00PMのようになっていて、このままだとDateオブジェクトに変換するときにうまくパースしてくれません。
そこで、この日時の文字列をDateオブジェクトが認識できる形に変換する必要があります。
Dateオブジェクトが認識してくれつつ、可能な限り簡潔に変換するために、以下のような関数を用意します。

function convertDate(dateString) {
  var meridiem = dateString.slice(-2);
  dateString = dateString.replace('at ', '').slice(0, -2);
  var date = new Date(dateString);
  if (meridiem === 'PM') {
    date.setHours(date.getHours() + 12);
  }
  return date;
}

June 24, 2019 at 07:00PMといった文字列がJune 24, 2019 07:00というDateオブジェクトのパーサーに理解可能な形に変換され、最後にPMの場合は12時間分加算されるという流れです。

以上から、IFTTTから送られるタイトルがtitleで開始日時がstartで終了日時がendだとすると、以下のように実行すれば、その予定を特定できます。

findCalendarEvent(convertDate(start), convertDate(end), title)

ゲスト情報の取得とメンション

カレンダーの予定を取得することはできたので、続いて、そこからゲスト情報を取得した上で、最終的に投稿する際に必要なメンションへの変換を行います。
方針としては、getGuestList()でゲスト情報を取得しつつ、これをループで回して、メールアドレスとメンションの情報を連想配列で保持しておき、そこから取得するという流れにします。

以下のように、notifyToSlack(title, description)の修正とslackMentionIdsの追加を行います。

slackMentionIds = {
  'ohshige@sample.com': 'UAAAAAAAA',
  'tanaka@sample.com': 'UBBBBBBBB',
  'suzuki@sample.com': 'UCCCCCCCC',
  'takahashi@samole.com': 'UDDDDDDDD',
}

function notifyToSlack(title, description, start, end) {
  var calendarEvent = findCalendarEvent(convertDate(start), convertDate(end), title);
  if (calendarEvent === null) {
    return;
  }

  var mentions = [];
  calendarEvent.getGuestList().forEach(function(guest) {
    var email = guest.getEmail();
    if (slackMentionIds[email]) {
      mentions.push('<@' + slackMentionIds[email] + '>');
    }
  }, mentions);

  var message = mentions.join(' ') + '\n「' + title + '」の予定が終わりました。\n以下をご確認下さい。\n---\n' + description;

  postSlack(message);
}

メールアドレスとSlackでのメンバーIDの対応をslackMentionIdsに設定しておくことで、メンションが必要なゲストを特定しています。
これは連想配列ではなくスプレッドシートで管理することもできて、その方が運用は楽な気がしますが、今回はそこまでやりません。

注意点として、getGuestList()では予定作成者は返却されないので結果的にメンションに予定作成者が含まれることはありません。
getGuestList(includeOwner)というメソッドもあるので、予定作成者が必要ならそちらを使うと良いと思います。

IFTTTの修正とGASの最終版

IFTTTから送るべき情報が増えたので、以下のようにIFTTTの設定を修正します。

項目 内容
URL GASの公開URL
Method POST
Content Type application/x-www-form-url
Body <<<{{Title}}>>>&<<<{{Description}}>>>&<<<{{StartTime}}>>>&<<<{{EndTime}}>>>

そして、IFTTTからのPOSTを受け取れるように修正した上でのGASの最終版が以下です。

slackMentionIds = {
  'ohshige@sample.com': 'UAAAAAAAA',
  'tanaka@sample.com': 'UBBBBBBBB',
  'suzuki@sample.com': 'UCCCCCCCC',
  'takahashi@samole.com': 'UDDDDDDDD',
}

function doPost(e) {
  var params = e.postData.contents.split('&');
  var title = decodeURIComponent(params[0].replace(/\+/g, ' '));
  var description = decodeURIComponent(params[1].replace(/\+/g, ' '));
  var start = decodeURIComponent(params[2].replace(/\+/g, ' '));
  var end = decodeURIComponent(params[3].replace(/\+/g, ' '));
  notifyToSlack(title, description, start, end);
}

function notifyToSlack(title, description, start, end) {
  var calendarEvent = findCalendarEvent(convertDate(start), convertDate(end), title);
  if (calendarEvent === null) {
    return;
  }

  var mentions = [];
  calendarEvent.getGuestList().forEach(function(guest) {
    var email = guest.getEmail();
    if (slackMentionIds[email]) {
      mentions.push('<@' + slackMentionIds[email] + '>');
    }
  }, mentions);

  var message = mentions.join(' ') + '\n「' + title + '」の予定が終わりました。\n以下をご確認下さい。\n---\n' + description;

  postSlack(message);
}

function findCalendarEvent(startTime, endTime, keyword) {
  var options = {
    'max': 1,
    'search': keyword
  }
  var calEventList = CalendarApp.getDefaultCalendar().getEvents(startTime, endTime, options);

  if (calEventList.length === 0) {
    return null;
  }

  return calEventList[0];
}

function convertDate(dateString) {
  var meridiem = dateString.slice(-2);
  dateString = dateString.replace('at ', '').slice(0, -2);
  var date = new Date(dateString);
  if (meridiem === 'PM') {
    date.setHours(date.getHours() + 12);
  }
  return date;
}

function postSlack(message) {
  var data = {
    'text': message
  };
  var options = {
    'method' : 'post',
    'contentType': 'application/json',
    'payload' : JSON.stringify(data)
  };
  UrlFetchApp.fetch('https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', options);
}

試してみる

「要確認」というキーワードを含めた上で適当に予定を作り、その予定が終わるのを待つと、Slackへ通知が来ます。
予定作成者が自分でゲストが誰もいないとメンションは含まれないので、適当にゲストを追加した上でそのメールアドレスとSlackでのメンバーIDの対応をslackMentionIdsに追加する必要があります。
これで完成です!

おわりに

ゲスト情報を取るためにカレンダーの予定を1つに特定する方法が意外と面倒で大変でした。
もしかしたら、他に簡単に取得できる方法があるかもしれませんが、あればどなたか教えて下さい。

とりあえず、知人からの依頼は達成でき、ちゃんと稼働しているようです。
お礼になにか奢ってもらいます!