👌

Googleカレンダー招待Bot "Inviter" を作りたい

2022/08/13に公開

会社でイベントなどが開催されるときに、「このイベント参加したい人は、スタンプ押してください!」みたいな投稿が社内Slackに現れます。

このときに主催者は逐一スタンプを確認して、スタンプを押した人に招待を投げる必要があります。
この運用をどうにか自動化できないかと思い、Googleカレンダー招待Bot 通称"Inviter"を作成してみました。

以下のスクラップに試行錯誤の過程を書いております。
https://zenn.dev/10inoino/scraps/68ccf546a5b36e

前提条件

  • Googleカレンダーを使っている組織であること
  • Slackのメールアドレスを全員Gmailで登録していること
  • Inviter用のGoogleアカウントを作成すること

全体の流れ

イベントの運営者がイベントを作成してから、参加者に招待が飛ぶまでの流れは以下の通りです。

  1. イベント運営者が自分のGoogleカレンダーでイベントを作成し、そのイベントにInviterを招待する。
  2. Inviterは招待されたことをトリガーに、Slackにイベント情報を投稿する。
  3. 参加希望者は、そのイベント情報にスタンプを押す。
  4. スタンプが押されたことをトリガーに、そのイベントにInviterが招待する。

実装内容

SlackAPIからBotを作成する

こちらは他に詳しく説明している記事があるので、詳しくは書きません。
Incoming webhooksと、Event Subscriptionsを有効化し、reaction_addedのイベントを購読してください。
これで、スタンプが押されたことをトリガーにGASを発火させることができます。

Event SubscriptionのRequest URLには、作成するGASのURLを設定してください。

また、以下のように権限を設定してください。

Inviterが招待されたらSlackにイベント内容を投稿する

Google Calendar APIでは、syncTokenという仕組みを利用して、APIがキックされたときの前回との差分を取得することで、更新されたイベントを特定します。

https://developers.google.com/apps-script/advanced/calendar?authuser=0#synchronizing_events

なので、以下のプログラムをGAS上で実行させて、現在の情報をプロパティに入れます。

const calendarId = "inviter用のgmailアドレス";

function logSyncedEvents() {
  const properties = PropertiesService.getUserProperties();
  const syncToken = properties.getProperty('syncToken');
  const options = {
    maxResults: 100,
    syncToken: syncToken,
  };

  let events;
  let pageToken;
  do {
    options.pageToken = pageToken;
    events = Calendar.Events.list(calendarId, options);
    if (events.items && events.items.length === 0) {
      Logger.log('No events found.');
      return;
    }
    pageToken = events.nextPageToken;
  } while (pageToken);
  properties.setProperty('syncToken', events.nextSyncToken);
}

上記が終わったら、イベントの差分が取得可能です。
以下のコードを書き、カレンダーの変更をトリガーにonCalendarEditを実行するようにします。

const calendarId = "inviter用のgmailアドレス";

function onCalendarEdit(){
  try{
    var properties = PropertiesService.getUserProperties();
    var nextSyncToken = properties.getProperty("syncToken");
    var optionalArgs = {
      syncToken: nextSyncToken
    };
    var events = Calendar.Events.list(calendarId, optionalArgs);

    const event = events.items[0];
    const eventId = event.id;
    const eventName = event.summary;
    const eventTime = dateToString(event.start.dateTime) + " ~ " + dateToString(event.end.dateTime);
    const organizerEmail = event.organizer.email;
    let location = "";
    if (event.location) location = event.location;
    let description = "";
    if (event.description) description = event.description;

    const eventObject = {
      ":id:": "`" + eventId + "`",
      ":tada:": eventName,
      ":clock3:": eventTime,
      ":man-raising-hand:": organizerEmail,
      ":house:": location,
      ":memo:": description,
    }

    const messagePayloads =
    [
      {
        "type": "header",
        "text": {
          "type": "plain_text",
          "text": ":large_blue_circle: Event Created",
          "emoji": true
        }
      },
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": createMessage(eventObject) + "\n\n参加したい方は:raised_hand:を押してください。私が招待します。"
        }
      }
    ]
    
    postRichMessageToSlack(messagePayloads);
  }catch(e){
    postErrorToSlack(JSON.stringify(e.message));
  }
}

function createMessage(messageObject) {
  let message = ""
  for(var key in messageObject){
    message += key + "  " + messageObject[key] + "\n";
  }
  return message
}

function dateToString(date) {
  return date.replace("T", " ").slice(0, -6);
}
// incoming webhookのURL
const postUrl = 'https://hooks.slack.com/services/xxxxxxxxxxxxxxxxxxxxxxxxxxx';

function postRichMessageToSlack(messagePayloads) {
  const jsonData =
  {
    "blocks": messagePayloads
  }

  const payload = JSON.stringify(jsonData);

  const options =
  {
    "method" : "post",
    "contentType" : "application/json",
    "payload" : payload
  };

  UrlFetchApp.fetch(postUrl, options);
}

function postErrorToSlack(errorMessage) {
  const messageObject =
  [
		{
			"type": "header",
			"text": {
				"type": "plain_text",
				"text": ":red_circle: Error",
				"emoji": true
			}
		},
		{
			"type": "section",
			"text": {
				"type": "plain_text",
				"text": errorMessage,
				"emoji": true
			}
		}
	];
  postRichMessageToSlack(messageObject);
}


(スペルが間違えているのは気にしないでください)

すると、Inviterが招待されたときに、以下の内容がSlackに流れます。

ちなみに、投稿内容は以下のBlock Kit Builderで色々作れるので、お気に入りのデザインにしてみてください。

https://app.slack.com/block-kit-builder/T02RP3KED1P

スタンプが押されたら、スタンプを押した人を招待する。

// この絵文字を押したら参加
const joinEmoji = "hand";
// このチャネルのスタンプのみ反応する
const targetChannelId = "XXXXXXXXXXXX";

const botUserOauthToken = "xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
const userOauthToken = "xoxp-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
const userInfoApi = "https://slack.com/api/users.info";
const conversationsRepliesApi = "https://slack.com/api/conversations.replies";

// スタンプが押されたらdoPostが発火
function doPost(e){
  try{
    const contents = JSON.parse(e.postData.contents);
    const event = contents.event;
    if (event.item.channel != targetChannelId || event.reaction != joinEmoji) return;

    const email = getEmail(event.user);
    const calendarId = getCalendarId(event.item.channel, event.item.ts);

    addGuest(calendarId, email);
  }catch(e){
    postErrorToSlack(e.message);
  }
}

function getEmail(userId) {
  const url = userInfoApi + "?user=" + userId;
  const response = getApi(botUserOauthToken, url);
  return response.user.profile.email;
}

function getCalendarId(channel, timestamp) {
  const url = conversationsRepliesApi + "?channel=" + channel + "&ts=" + timestamp;
  const response = getApi(userOauthToken, url);
  const text = response.messages[0].blocks[1].text.text;
  // 正規表現でイベントのIDのみを抜き出す
  const calendarId = text.match(/[0-9a-z]{26}/);
  return calendarId[0];
}

function getApi(authToken, url) {
  const headers = {
    "Authorization": "Bearer " + authToken,
  };
  const options = {
     "method": "get",
     "headers": headers,
     "muteHttpExceptions": true,
  };
  const rowResponse = UrlFetchApp.fetch(url, options);
  return JSON.parse(rowResponse.getContentText());
}

function addGuest(calendarId, addEmail) {
  var calendar = CalendarApp.getDefaultCalendar();
  var event = calendar.getEventById(calendarId);
  event.addGuest(addEmail);
}

スタンプが押された情報からは、以下のような情報が取得できます。

{
    "token": "xxxxx",
    "team_id": "xxxxx",
    "api_app_id": "xxxxx",
    "event": {
        "type": "reaction_added",
        "user": "xxxxx",
        "reaction": "white_check_mark",
        "item": {
        "type": "message",
        "channel": "xxxxx",
        "ts": "1660215621.166599"
        },
        "event_ts": "1660215873.001800"
    },
    "type": "event_callback",
    "event_id": "xxxxx",
    "event_time": 1660215873,
    "authorizations": [
        {
        "enterprise_id": null,
        "team_id": "xxxxx",
        "user_id": "xxxxx",
        "is_bot": false,
        "is_enterprise_install": false
        }
    ],
    "is_ext_shared_channel": false,
    "event_context": "x-xxxxx"
}

この情報からは、スタンプを押した人のメールアドレスと、スタンプを押したGoogleカレンダーのイベント情報がわかりません。

なので、メールアドレスはusers.infoのAPIから、Googleカレンダーのイベント情報はconversations.repliesから投稿内容を取得し、そこから正規表現で、イベントのIDを抜き出します。

https://api.slack.com/methods/users.info

https://api.slack.com/methods/conversations.replies

そして、以下の箇所でイベントに招待します。

招待してるところ
function addGuest(calendarId, addEmail) {
  var calendar = CalendarApp.getDefaultCalendar();
  var event = calendar.getEventById(calendarId);
  event.addGuest(addEmail);
}

まとめ

GoogleとSlackが提供しているAPIを組み合わせると、結構自由度高くいろんなことができるんだなという気づきがありました。
執筆現在ではまだ組織で運用できていないので、間違っている点や改善点等あればコメントいただけると幸いです!

Discussion