Closed47

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

10inoino10inoino

社内イベントが開かれることが多いが、そのたびにSlackで「〇〇の勉強会に参加したい人はこのメッセージにスタンプを押してください!」と主催者が投稿し、スタンプがついたら手動で招待するという作業が発生している。
これをSlackBotで自動化できないものだろうか

10inoino10inoino

GoogleFormでイベント内容を投稿
→ incoming webhookが発火してイベント内容がSlackに投稿される
→ 投稿されたイベントにメンバーがスタンプを押す
→ スタンプを押したことをトリガーにGASが走って、Googleカレンダーの招待が飛ぶ

みたいな感じにできねぇかな

10inoino10inoino

Subscribe to events on behalf of usersの方にも追加してみる

10inoino10inoino

次にスタンプのアクションから来るリクエストのデータをみてみたい

10inoino10inoino

ただリクエストをJSONで吐き出しただけ

function doPost(e){
  var postUrl = 'https://hooks.slack.com/services/xxxxxxxxxxxxxxxxxxxxxxxx';
  var message = JSON.stringify(e);
  
  var jsonData =
  {
     "text" : message
  };
  var payload = JSON.stringify(jsonData);

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

  UrlFetchApp.fetch(postUrl, options);
}
10inoino10inoino

関係ありそうなPostData.contentsだけ取り出すとこんな感じ

{
    "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"
}
  
10inoino10inoino

この情報だとメールアドレスは取れないのか
user_idからメールアドレスの取得までいくしかなさそう

10inoino10inoino

でますね

{
    "ok": true,
    "user": {
        "id": "xxxxx",
        "team_id": "xxxxx",
        "name": "inomatahi",
        "deleted": false,
        "color": "9f69e7",
        "real_name": "inomatahi",
        "tz": "Asia/Tokyo",
        "tz_label": "Japan Standard Time",
        "tz_offset": 32400,
        "profile": {
            "title": "",
            "phone": "",
            "skype": "",
            "real_name": "inomatahi",
            "real_name_normalized": "inomatahi",
            "display_name": "",
            "display_name_normalized": "",
            "fields": null,
            "status_text": "In a meeting • Google Calendar",
            "status_emoji": ":spiral_calendar_pad:",
            "status_emoji_display_info": [],
            "status_expiration": 000000,
            "avatar_hash": "xxxxx",
            "huddle_state": "default_unset",
            "image_24": "xxxxx",
            "status_text_canonical": "",
            "team": "xxxxx"
        },
        "is_admin": true,
        "is_owner": true,
        "is_primary_owner": true,
        "is_restricted": false,
        "is_ultra_restricted": false,
        "is_bot": false,
        "is_app_user": false,
        "updated": 000000,
        "is_email_confirmed": true,
        "who_can_share_contact_card": "EVERYONE"
    }
}
10inoino10inoino

今更気が付いたが、スタンプ押された時のリクエストの情報から、投稿内容を参照する方法がなくないか?
これができないと、そもそもどの勉強会の情報に対してスタンプが押されたのか分からんぞ?

10inoino10inoino

読む感じだと、パブリックチャネルの情報を受け取りたいだけなのであれば、channels:historyの権限だけでいいのかな?

10inoino10inoino

なるほど
メッセージを取得するためにはユーザートークンが必要なのね

To use conversations.replies with public or private channel threads, use a user token with channels:history or groups:history scopes.

conversations.repliesをパブリックまたはプライベートチャンネルスレッドで使用するには、channels:historyまたはgroups:historyスコープを持つユーザートークンを使用します。

10inoino10inoino

開発の流れは何となくわかった

  • GoogleFormでイベント登録
  • イベント情報をスプレッドシートに保存
  • スプレッドシートに保存されたトリガーで特定のチャネルにイベント情報投稿
  • イベントにスタンプが付いたら、GASが発火
  • リクエストのuser_idを使って、users.infoのAPIを叩いて、ユーザーのメールアドレスを取得
  • リクエストのchanneltsを使って、conversations.repliesのAPIを叩いて、投稿内容を取得
  • 投稿内容からイベント情報を取得して、メールアドレスに対してGoogleカレンダーのAPIで予定を入れる
10inoino10inoino

まだ分かってない点としては、Googleカレンダーへのアクセス権限をどのように開放するか

10inoino10inoino

API実行時のトークンが2種類あるのも、ややこしポイントだな
実行時のBotTokenの権限を利用したいか、UserTokenの権限を利用したいかで、どちらを使うかが変わってくるんだろう


10inoino10inoino

ふと思ったが、最初のイベントの登録をGoogleFormで行うんじゃなくて、イベント登録用のカレンダーを作成して、そこにイベントを登録したのをトリガーにGASを発火させればいいのでは?
で、そのイベント用のカレンダーに追加で招待する感じ

10inoino10inoino

nextSyncTokenが取得できない?
カレンダーIDの認証とかが必要なのか?

10inoino10inoino

あー理解した
こんな感じかな

var calendarId = "primary";

// こちらで現在のイベントをpropertiesに保存
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);
}

// イベント変更されたときはこちらを叩いて差分を取得
function onCalendarEdit() {
  var properties = PropertiesService.getUserProperties();
  var nextSyncToken = properties.getProperty("syncToken");
  var optionalArgs = {
    syncToken: nextSyncToken
  };
  var events = Calendar.Events.list(calendarId, optionalArgs);
  postToSlack(JSON.stringify(events))
  var nextSyncToken = events["nextSyncToken"];
  properties.setProperty("syncToken", nextSyncToken);
}
10inoino10inoino

取得できるイベントの情報はこんな感じ

イベント作成時

{
  "kind": "calendar#events",
  "timeZone": "Asia/Tokyo",
  "etag": "\"p33s9d2legf1vi0g\"",
  "summary": "inomatahi@gmail.com",
  "defaultReminders": [{ "method": "popup", "minutes": 30 }],
  "items": [
    {
      "etag": "\"3320733078198000\"",
      "description": "説明はここ",
      "hangoutLink": "https://meet.google.com/xxx-xxx-xxx",
      "iCalUID": "xxxxxxxx@google.com",
      "end": {
        "timeZone": "Asia/Tokyo",
        "dateTime": "2022-08-13T15:00:00+09:00"
      },
      "kind": "calendar#event",
      "reminders": { "useDefault": true },
      "start": {
        "timeZone": "Asia/Tokyo",
        "dateTime": "2022-08-13T14:00:00+09:00"
      },
      "organizer": { "email": "xxxxx@gmail.com", "self": true },
      "updated": "2022-08-13T04:55:39.099Z",
      "creator": { "email": "xxxxx@gmail.com", "self": true },
      "id": "xxxxxxxx",
      "eventType": "default",
      "htmlLink": "https://www.google.com/calendar/event?eid=xxxxx",
      "status": "confirmed",
      "summary": "イベント作成テスト",
      "sequence": 0,
      "created": "2022-08-13T04:55:39.000Z",
      "conferenceData": {
        "conferenceSolution": {
          "iconUri": "https://fonts.gstatic.com/s/i/productlogos/xxxxx",
          "name": "Google Meet",
          "key": { "type": "hangoutsMeet" }
        },
        "conferenceId": "xxx-xxxx-xxx",
        "entryPoints": [
          {
            "uri": "https://meet.google.com/xxx-xxxx-xxx",
            "entryPointType": "video",
            "label": "meet.google.com/xxx-xxxx-xxxx"
          }
        ]
      },
      "location": "場所はここ"
    }
  ],
  "accessRole": "owner",
  "nextSyncToken": "xxxxxxxxxxxxxxxxx=",
  "updated": "2022-08-13T04:55:39.099Z"
}

イベント編集時

{
  "etag": "\"p32cchsljgf1vi0g\"",
  "items": [
    {
      "iCalUID": "xxxxx@google.com",
      "kind": "calendar#event",
      "summary": "イベント変更テスト",
      "hangoutLink": "https://meet.google.com/xxx-xxxx-xxx",
      "organizer": { "self": true, "email": "inomatahi@gmail.com" },
      "htmlLink": "https://www.google.com/calendar/event?eid=xxxxxxxx",
      "conferenceData": {
        "conferenceSolution": {
          "name": "Google Meet",
          "iconUri": "https://fonts.gstatic.com/s/i/productlogos/xxxxxxxx",
          "key": { "type": "hangoutsMeet" }
        },
        "conferenceId": "xxx-xxxx-xxx",
        "entryPoints": [
          {
            "uri": "https://meet.google.com/xxx-xxxx-xxx",
            "entryPointType": "video",
            "label": "meet.google.com/xxx-xxxx-xxx"
          }
        ]
      },
      "status": "confirmed",
      "updated": "2022-08-13T04:55:51.295Z",
      "eventType": "default",
      "description": "説明はここ",
      "id": "7h4vck092338k6ot6mni7ipndo",
      "etag": "\"3320733102590000\"",
      "end": {
        "timeZone": "Asia/Tokyo",
        "dateTime": "2022-08-13T15:00:00+09:00"
      },
      "creator": { "self": true, "email": "xxxxx@gmail.com" },
      "created": "2022-08-13T04:55:39.000Z",
      "start": {
        "dateTime": "2022-08-13T14:00:00+09:00",
        "timeZone": "Asia/Tokyo"
      },
      "location": "場所はここ",
      "reminders": { "useDefault": true },
      "sequence": 0
    }
  ],
  "defaultReminders": [{ "minutes": 30, "method": "popup" }],
  "nextSyncToken": "xxxxxxxx",
  "kind": "calendar#events",
  "timeZone": "Asia/Tokyo",
  "updated": "2022-08-13T04:55:51.295Z",
  "summary": "xxxxxx@gmail.com",
  "accessRole": "owner"
}

イベント削除時

{
  "kind": "calendar#events",
  "timeZone": "Asia/Tokyo",
  "updated": "2022-08-13T04:55:54.228Z",
  "defaultReminders": [{ "method": "popup", "minutes": 30 }],
  "items": [
    {
      "id": "7h4vck092338k6ot6mni7ipndo",
      "etag": "\"3320733108456000\"",
      "status": "cancelled",
      "kind": "calendar#event"
    }
  ],
  "accessRole": "owner",
  "etag": "\"p32gcl9dlgf1vi0g\"",
  "summary": "xxxxx@gmail.com",
  "nextSyncToken": "xxxxxxxx"
}
10inoino10inoino

idがあるから、idを参照して招待ができれば一件落着なんだよな

10inoino10inoino

これで流れはわかった

  • 招待Bot用のGoogleアカウント作成
  • イベントを作りたい人は、自分でGoogleカレンダーのイベントを作った上で、招待Botを参加者に加える
  • 招待Botは、イベントに招待されたら、そのイベントをSlackに投下
  • 参加したい人は、その投稿にスタンプを送る
  • イベントにスタンプが付いたら、GASが発火
  • リクエストのuser_idを使って、users.infoのAPIを叩いて、ユーザーのメールアドレスを取得
  • リクエストのchanneltsを使って、conversations.repliesのAPIを叩いて、投稿内容を取得
  • 投稿内容からイベントIDを取得して、メールアドレスに対してGoogle Calendar APIで予定に招待
10inoino10inoino

SlackのAPIを叩くのに認証情報をHeaderにいれる必要があるんだけど、GASだとどうやんだろ

10inoino10inoino

とりあえずこんな感じ(イベントの作成とか変更をトリガーに起動するやつ)

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)
        }
      }
    ]
    
    postRichMessageToSlack(messagePayloads);
  }catch(e){
    postErrorToSlack(JSON.stringify(e));
  }
}

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);
}

var postUrl = 'https://hooks.slack.com/services/xxxxxxxxxxxxxxxxxxxxxxxxxxxx';

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

  var payload = JSON.stringify(jsonData);

  var 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);
}
10inoino10inoino

GASってこんな感じでreturnすると例外の方入っちゃうのか?

function doPost(e){
  try{
    const event = e.event;
    if (event.channel != targetChannelId || event.reaction != joinEmoji) return;

    postToSlack("OK");
  }catch(e){
    postErrorToSlack(JSON.stringify(e));
  }
}
10inoino10inoino

いや、そんなことはなかった
一度parseしてやる必要があるんだ

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

function doPost(e){
  try{
    const contentsJson = e.postData.contents;
    const contents = JSON.parse(contentsJson);
    const event = contents.event;
    postToSlack(JSON.stringify(contents));
    if (event.item.channel != targetChannelId || event.reaction != joinEmoji) return;

    postToSlack("OK");
  }catch(e){
    postErrorToSlack(JSON.stringify(e));
  }
}
10inoino10inoino

スタンプを押したときの挙動はこんな感じかな

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

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

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 headers = {
    "Authorization": "Bearer " + botUserOauthToken,
  };
  const options = {
     "method": "get",
     "headers": headers,
     "muteHttpExceptions": true,
  };
  const rowResponse = UrlFetchApp.fetch(url, options);
  const response = JSON.parse(rowResponse.getContentText());
  return response.user.profile.email;
}

function getCalendarId(channel, timestamp) {
  const url = conversationsRepliesApi + "?channel=" + channel + "&ts=" + timestamp;

  const headers = {
    "Authorization": "Bearer " + userOauthToken,
  };
  const options = {
     "method": "get",
     "headers": headers,
     "muteHttpExceptions": true,
  };
  const rowResponse = UrlFetchApp.fetch(url, options);
  const response = JSON.parse(rowResponse.getContentText());
  const text = response.messages[0].blocks[1].text.text;
  const calendarId = text.match(/[0-9a-z]{26}/);
  return calendarId[0];
}

function addGuest(calendarId, addEmail) {
  var calendar = CalendarApp.getDefaultCalendar();
  var event = calendar.getEventById(calendarId);
  event.addGuest(addEmail);
}
このスクラップは2022/08/13にクローズされました