Googleカレンダー招待Botを作りたい
社内イベントが開かれることが多いが、そのたびにSlackで「〇〇の勉強会に参加したい人はこのメッセージにスタンプを押してください!」と主催者が投稿し、スタンプがついたら手動で招待するという作業が発生している。
これをSlackBotで自動化できないものだろうか
GoogleFormでイベント内容を投稿
→ incoming webhookが発火してイベント内容がSlackに投稿される
→ 投稿されたイベントにメンバーがスタンプを押す
→ スタンプを押したことをトリガーにGASが走って、Googleカレンダーの招待が飛ぶ
みたいな感じにできねぇかな
これでGoogleカレンダーの投稿はできた
ただ、Googleカレンダーへのアクセス権限はどうなるんだろう
私の作ったアプリだから、私は権限開放できたけども
続いてこちらを参考にしてみる
とりあえずreaction_added
だけ追加してみた
スタンプを押しても特に反応しないぞ?
Postmanから叩くとAPI実行は成功するので、APIは問題ないっぽい
Subscribe to events on behalf of users
の方にも追加してみる
反応しねぇなぁ
あ、アプリ再インストールしたらいけたわ
次にスタンプのアクションから来るリクエストのデータをみてみたい
ただリクエストを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);
}
関係ありそうな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"
}
この情報だとメールアドレスは取れないのか
user_idからメールアドレスの取得までいくしかなさそう
これか?
ちがうな
これはEmailから探してるのか
これっぽいな
でますね
{
"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"
}
}
ただメールアドレスがない?
なるほど
emailは別の権限なのか
今更気が付いたが、スタンプ押された時のリクエストの情報から、投稿内容を参照する方法がなくないか?
これができないと、そもそもどの勉強会の情報に対してスタンプが押されたのか分からんぞ?
これか?
読む感じだと、パブリックチャネルの情報を受け取りたいだけなのであれば、channels:history
の権限だけでいいのかな?
なるほど
メッセージを取得するためにはユーザートークンが必要なのね
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スコープを持つユーザートークンを使用します。
開発の流れは何となくわかった
- GoogleFormでイベント登録
- イベント情報をスプレッドシートに保存
- スプレッドシートに保存されたトリガーで特定のチャネルにイベント情報投稿
- イベントにスタンプが付いたら、GASが発火
- リクエストの
user_id
を使って、users.infoのAPIを叩いて、ユーザーのメールアドレスを取得 - リクエストの
channel
とts
を使って、conversations.repliesのAPIを叩いて、投稿内容を取得 - 投稿内容からイベント情報を取得して、メールアドレスに対してGoogleカレンダーのAPIで予定を入れる
まだ分かってない点としては、Googleカレンダーへのアクセス権限をどのように開放するか
API実行時のトークンが2種類あるのも、ややこしポイントだな
実行時のBotTokenの権限を利用したいか、UserTokenの権限を利用したいかで、どちらを使うかが変わってくるんだろう
ふと思ったが、最初のイベントの登録をGoogleFormで行うんじゃなくて、イベント登録用のカレンダーを作成して、そこにイベントを登録したのをトリガーにGASを発火させればいいのでは?
で、そのイベント用のカレンダーに追加で招待する感じ
これすごく参考になりそう
nextSyncTokenが取得できない?
カレンダーIDの認証とかが必要なのか?
ここらへんを読んでみる
あー理解した
こんな感じかな
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);
}
calendarId
をprimary
にすると、自分のアカウントのデフォルトカレンダーが参照されるらしい
取得できるイベントの情報はこんな感じ
イベント作成時
{
"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"
}
idがあるから、idを参照して招待ができれば一件落着なんだよな
お、割とサクッとできた
// イベント招待
function addGuest() {
var calendar = CalendarApp.getDefaultCalendar();
var event = calendar.getEventById("ここにid");
event.addGuest("ここにメールアドレス");
}
参考資料はこちら
これで流れはわかった
- 招待Bot用のGoogleアカウント作成
- イベントを作りたい人は、自分でGoogleカレンダーのイベントを作った上で、招待Botを参加者に加える
- 招待Botは、イベントに招待されたら、そのイベントをSlackに投下
- 参加したい人は、その投稿にスタンプを送る
- イベントにスタンプが付いたら、GASが発火
- リクエストの
user_id
を使って、users.infoのAPIを叩いて、ユーザーのメールアドレスを取得 - リクエストの
channel
とts
を使って、conversations.repliesのAPIを叩いて、投稿内容を取得 - 投稿内容からイベントIDを取得して、メールアドレスに対してGoogle Calendar APIで予定に招待
SlackのAPIを叩くのに認証情報をHeaderにいれる必要があるんだけど、GASだとどうやんだろ
これだな
この前見つけたんだが、こんな感じでリッチなメッセージ送れるんよね
GASのDatetime変換
とりあえずこんな感じ(イベントの作成とか変更をトリガーに起動するやつ)
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);
}
Slackはこんな感じ
アイコン分かりづらいかなぁ
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));
}
}
いや、そんなことはなかった
一度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));
}
}
スタンプを押したときの挙動はこんな感じかな
// この絵文字を押したら参加
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);
}