Last active
August 30, 2024 07:09
-
-
Save yuhui/a3103d31c505a41aa9db58ae7192b894 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this script to a Google Apps Script project. | |
See the JSdoc at `autoDeclineMeetings()` for trigger setup instructions. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const CALENDAR_ID = 'primary'; | |
const DAYS_OF_WEEK = [ | |
'Sun', | |
'Mon', | |
'Tue', | |
'Wed', | |
'Thu', | |
'Fri', | |
'Sat', | |
]; | |
const INTERNAL_DOMAINS = [ | |
'domain1.com', | |
'domain2.com', | |
]; | |
const MANAGER_EMAIL_ADDRESSES = [ | |
'[email protected]', | |
'[email protected]', | |
]; | |
const MEETING_DECLINE_REASON = 'Declined because there is no agenda.'; | |
/** | |
* Main function. | |
* When there is an internal meeting without a description, then auto-decline it. | |
* | |
* Use this with a Trigger. Configure the trigger as follows: | |
* - Event source: "From calendar" | |
* - Event details: "Calendar updated", your own email address | |
*/ | |
function autoDeclineMeetings() { | |
const meetings = getMeetingsWithoutDescriptions_(); | |
meetings.forEach((meeting) => { | |
const { id, summary, creator, start, end } = meeting; | |
const log = [ | |
`${summary} (${id})`, | |
`When: ${start} - ${end}`, | |
`Creator: ${JSON.stringify(creator)}`, | |
'--', | |
]; | |
Logger.log(`${log.join('\n')}`); | |
meeting.attendees.forEach((attendee) => { | |
if (attendee.self) { | |
attendee.responseStatus = 'declined'; | |
attendee.comment = `${MEETING_DECLINE_REASON}`; | |
} | |
}); | |
Calendar.Events.update(meeting, CALENDAR_ID, meeting.id, {}, { 'If-Match': meeting.etag }); | |
const { email } = creator; | |
const replySubject = `Declined: ${summary} @ ${stringifyEventDateTime_(meeting)} (${email})`; | |
const replyBody = `${MEETING_DECLINE_REASON}`; | |
mail_(email, replySubject, replyBody, email); | |
// also, send an email to self as a notification of the auto-decline | |
const mailTo = `${Session.getActiveUser().getEmail()}`; | |
const mailSubject = `Auto-declined Invitation: ${summary} @ ${stringifyEventDateTime_(meeting)}`; | |
const mailBody = `Sent from ${scriptUrl_()} .`; | |
mail_(mailTo, mailSubject, mailBody); | |
}); | |
} | |
/** | |
* Keep meetings that have the following conditions: | |
* - no description | |
* - not created by myself | |
* - not created by my manager | |
* - pending my response | |
* | |
* @return {Array} Meetings without descriptions that are pending RSVP. | |
*/ | |
const getMeetingsWithoutDescriptions_ = () => { | |
const meetings = getMeetings_(); | |
const meetingsWithoutDescriptions_ = meetings.filter((meeting) => { | |
const { attendees, creator, description } = meeting; | |
const { email, self } = creator; | |
const isInternalCreator = INTERNAL_DOMAINS.some((domain) => email.endsWith(domain)); | |
const isMissingDescription = !description; | |
const isNotCreatedByManager = !MANAGER_EMAIL_ADDRESSES.includes(email); | |
const isNotCreatedBySelf = !self; | |
const myResponseNeedsAction = attendees.some((attendee) => { | |
const { self, responseStatus } = attendee; | |
return self && responseStatus === 'needsAction'; | |
}); | |
return isInternalCreator && isMissingDescription && isNotCreatedByManager && isNotCreatedBySelf && myResponseNeedsAction; | |
}); | |
return meetingsWithoutDescriptions_; | |
}; | |
/** | |
* Get all meetings from the primary calendar. | |
* | |
* @param {Boolean} fullSync (optional) If `true`, get all events in the last 30 days and re-sync update token. | |
* | |
* @return {Array} Meetings with attendees. | |
*/ | |
const getMeetings_ = (fullSync = false) => { | |
const options = {}; | |
const properties = PropertiesService.getUserProperties(); | |
const syncToken = properties.getProperty('syncToken'); | |
if (syncToken && !fullSync) { | |
options.syncToken = syncToken; | |
} else { | |
// Sync events up to thirty days in the past. | |
options.timeMin = getRelativeDate_(-30, 0).toISOString(); | |
} | |
// Retrieve events one page at a time. | |
let allMeetings = []; | |
let pageToken; | |
do { | |
let events; | |
try { | |
options.pageToken = pageToken; | |
events = Calendar.Events.list(CALENDAR_ID, options); | |
const items = events.items; | |
// keep only those items that need my auto-decline | |
const meetings = items.filter((item) => { | |
/** | |
* Keep items with the following conditions: | |
* - "default" eventTypes, because these are actual meetings | |
* - have more than 1 attendee (the creator and someone else) | |
*/ | |
const { attendees, eventType } = item; | |
const hasAttendees = !!attendees && attendees.length > 1; | |
const isDefaultEventType = eventType === 'default'; | |
return hasAttendees && isDefaultEventType; | |
}); | |
allMeetings = allMeetings.concat(meetings); | |
if (events.nextPageToken) { | |
pageToken = events.nextPageToken; | |
} else if (events.nextSyncToken) { | |
properties.setProperty('syncToken', events.nextSyncToken); | |
} | |
} catch (e) { | |
// Check to see if the sync token was invalidated by the server; | |
// if so, perform a full sync instead. | |
if (e.message === 'Sync token is no longer valid, a full sync is required.') { | |
properties.deleteProperty('syncToken'); | |
allMeetings = getMeetings_(true); | |
return allMeetings; | |
} | |
} | |
} while (pageToken); | |
return allMeetings; | |
}; | |
/** | |
* Helper function to get a new Date object relative to the current date. | |
* | |
* @param {Number} daysOffset The number of days in the future for the new date. | |
* @param {Number} hour The hour of the day for the new date, in the time zone of the script. | |
* | |
* @return {Date} The new date. | |
*/ | |
const getRelativeDate_ = (daysOffset, hour) => { | |
const date = new Date(); | |
date.setDate(date.getDate() + daysOffset); | |
date.setHours(hour); | |
date.setMinutes(0); | |
date.setSeconds(0); | |
date.setMilliseconds(0); | |
return date; | |
}; | |
/** | |
* Helper function to get the event's start and end date-times in a string that is similar to that used in Google Calendar responses. | |
*/ | |
const stringifyEventDateTime_ = (event) => { | |
const { start, end } = event; | |
const startDateTime = new Date(start.dateTime); | |
const endDateTime = new Date(end.dateTime); | |
const dateTimeString = `${stringifyDate_(startDateTime)} ${stringifyTime_(startDateTime)} - ${stringifyTime_(endDateTime)} (SGT)`; | |
return dateTimeString; | |
}; | |
/** | |
* Helper function to get the date in a string that is similar to that used in Google Calendar responses. | |
*/ | |
const stringifyDate_ = (dateTime) => { | |
const dateDay = DAYS_OF_WEEK[dateTime.getDay()]; | |
const dateYear = dateTime.getFullYear(); | |
const dateMonth = dateTime.getMonth() + 1; | |
const dateDate = dateTime.getDate(); | |
const dateString = `${dateDay} ${dateYear}-${padZero_(dateMonth)}-${padZero_(dateDate)}`; | |
return dateString; | |
}; | |
/** | |
* Helper function to get the time in a string that is similar to that used in Google Calendar responses. | |
*/ | |
const stringifyTime_ = (dateTime) => { | |
const timeHours = dateTime.getHours(); | |
const timeMinutes = dateTime.getMinutes(); | |
const timeOfDay = timeHours < 12 ? 'am' : 'pm'; | |
const timeString = `${padZero_(timeHours)}:${padZero_(timeMinutes)}${timeOfDay}`.replace(':00', ''); | |
return timeString; | |
}; | |
/** | |
* Add a leading '0' to a number if the number is less than 10. | |
*/ | |
const padZero_ = (int) => `${int < 10 ? '0' : ''}${int}`; | |
/** | |
* Send an email using Gmail. | |
* | |
* @param {String} to Address to send the email to. | |
* @param {String} subject Subject of the email. | |
* @param {String} body Body of the email as plaintext. | |
* @param {String} replyTo (optional) Address for the user to reply to. If it's a blank string, then set `noReply` to `true`. | |
*/ | |
const mail_ = (to, subject, body, replyTo = '') => { | |
const options = {}; | |
if (!replyTo) { | |
options.noReply = true; | |
} else { | |
options.replyTo = replyTo; | |
} | |
GmailApp.sendEmail(to, subject, body, options); | |
}; | |
/** | |
* Helper function to get the URL of this script. | |
*/ | |
const scriptUrl_ = () => { | |
const scriptId = ScriptApp.getScriptId(); | |
const scriptUrl = `https://script.google.com/home/projects/${scriptId}/edit`; | |
return scriptUrl; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment