The following shows an example of a Google Workspace add-on that extends Calendar to provide syncing with a fictional web conferencing service called "My Web Conferencing". When installed, this add-on lets users see My Web Conferencing as a conferencing option when editing Google Calendar events.
The sample shows conference creation, event syncing, and hosts a simple add-on setting page by deploying the add-on script as a web app. The sample disables homepages.
Add-on Manifest
{
"addOns": {
"calendar": {
"conferenceSolution": [{
"id": 1,
"name": "My Web Conference",
"logoUrl": "https://lh3.googleusercontent.com/...",
"onCreateFunction": "createConference"
}],
"createSettingsUrlFunction": "createSettingsUrl",
"currentEventAccess": "READ_WRITE"
},
"common": {
"homepageTrigger": {
"enabled": false
},
"logoUrl": "https://lh3.googleusercontent.com/...",
"name": "My Web Conferencing"
}
},
"timeZone": "America/New_York",
"dependencies": {
"enabledAdvancedServices": [
{
"userSymbol": "Calendar",
"serviceId": "calendar",
"version": "v3"
}
]
},
"webapp": {
"access": "ANYONE",
"executeAs": "USER_ACCESSING"
},
"exceptionLogging": "STACKDRIVER",
"oauthScopes": [
"https://www.googleapis.com/auth/calendar.addons.execute",
"https://www.googleapis.com/auth/calendar.events.readonly",
"https://www.googleapis.com/auth/calendar.addons.current.event.write",
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/script.scriptapp"
]
}
CreateConf.gs
/**
* Creates a conference, then builds and returns a ConferenceData object
* with the corresponding conference information. This method is called
* when a user selects a conference solution defined by the add-on that
* uses this function as its 'onCreateFunction' in the add-on manifest.
*
* @param {Object} arg The default argument passed to a 'onCreateFunction';
* it carries information about the Google Calendar event.
* @return {ConferenceData}
*/
function createConference(arg) {
const eventData = arg.eventData;
const calendarId = eventData.calendarId;
const eventId = eventData.eventId;
// Retrieve the Calendar event information using the Calendar
// Advanced service.
var calendarEvent;
try {
calendarEvent = Calendar.Events.get(calendarId, eventId);
} catch (err) {
// The calendar event does not exist just yet; just proceed with the
// given event ID and allow the event details to sync later.
console.log(err);
calendarEvent = {
id: eventId,
};
}
// Create a conference on the third-party service and return the
// conference data or errors in a custom JSON object.
var conferenceInfo = create3rdPartyConference(calendarEvent);
// Build and return a ConferenceData object, either with conference or
// error information.
var dataBuilder = ConferenceDataService.newConferenceDataBuilder();
if (!conferenceInfo.error) {
// No error, so build the ConferenceData object from the
// returned conference info.
var phoneEntryPoint = ConferenceDataService.newEntryPoint()
.setEntryPointType(ConferenceDataService.EntryPointType.PHONE)
.setUri('tel:+' + conferenceInfo.phoneNumber)
.setPin(conferenceInfo.phonePin);
var adminEmailParameter = ConferenceDataService.newConferenceParameter()
.setKey('adminEmail')
.setValue(conferenceInfo.adminEmail);
dataBuilder.setConferenceId(conferenceInfo.id)
.addEntryPoint(phoneEntryPoint)
.addConferenceParameter(adminEmailParameter)
.setNotes(conferenceInfo.conferenceLegalNotice);
if (conferenceInfo.videoUri) {
var videoEntryPoint = ConferenceDataService.newEntryPoint()
.setEntryPointType(ConferenceDataService.EntryPointType.VIDEO)
.setUri(conferenceInfo.videoUri)
.setPasscode(conferenceInfo.videoPasscode);
dataBuilder.addEntryPoint(videoEntryPoint);
}
// Since the conference creation request succeeded, make sure that
// syncing has been enabled.
initializeSyncing(calendarId, eventId, conferenceInfo.id);
} else if (conferenceInfo.error === 'AUTH') {
// Authenentication error. Implement a function to build the correct
// authenication URL for the third-party conferencing system.
var authenticationUrl = getAuthenticationUrl();
var error = ConferenceDataService.newConferenceError()
.setConferenceErrorType(
ConferenceDataService.ConferenceErrorType.AUTHENTICATION)
.setAuthenticationUrl(authenticationUrl);
dataBuilder.setError(error);
} else {
// Other error type;
var error = ConferenceDataService.newConferenceError()
.setConferenceErrorType(
ConferenceDataService.ConferenceErrorType.TEMPORARY);
dataBuilder.setError(error);
}
// Don't forget to build the ConferenceData object.
return dataBuilder.build();
}
/**
* Contact the third-party conferencing system to create a conference there,
* using the provided calendar event information. Collects and retuns the
* conference data returned by the third-party system in a custom JSON object
* with the following fields:
*
* data.adminEmail - the conference administrator's email
* data.conferenceLegalNotice - the conference legal notice text
* data.error - Only present if there was an error during
* conference creation. Equal to 'AUTH' if the add-on user needs to
* authorize on the third-party system.
* data.id - the conference ID
* data.phoneNumber - the conference phone entry point phone number
* data.phonePin - the conference phone entry point PIN
* data.videoPasscode - the conference video entry point passcode
* data.videoUri - the conference video entry point URI
*
* The above fields are specific to this example; which conference information
* you add-on needs is dependent on the third-party conferencing system
* requirements.
*
* @param {Object} calendarEvent A Calendar Event resource object returned by
* the Google Calendar API.
* @return {Object}
*/
function create3rdPartyConference(calendarEvent) {
var data = {};
// Get the add-on settings information to pass to the third-party system.
// Alternatively, store the add-on setting information on the third-party
// system.
var props = PropertiesService.getUserProperties();
var disableVideo = props.getProperty('disableVideo') || "false";
var namePrefix = props.getProperty('namePrefix')
// Implementation details dependent on the third-party system API.
// Typically one or more API calls are made to create the conference and
// acquire its relevant data, which is then put in to the returned JSON
// object.
return data;
}
/**
* Return the URL used to authenticate the user with the third-party
* conferencing system.
*
* @return {String}
*/
function getAuthenticationUrl() {
var url;
// Implementation details dependent on the third-party system.
return url;
}
Syncing.gs
/**
* Initializes syncing of conference data by creating a sync trigger and
* sync token if either does not exist yet.
*
* @param {String} calendarId The ID of the Google Calendar.
*/
function initializeSyncing(calendarId) {
// Create a syncing trigger if it doesn't exist yet.
createSyncTrigger(calendarId);
// Perform an event sync to create the initial sync token.
syncEvents({'calendarId': calendarId});
}
/**
* Creates a sync trigger if it does not exist yet.
*
* @param {String} calendarId The ID of the Google Calendar.
*/
function createSyncTrigger(calendarId) {
// Check to see if the trigger already exists; if does, return.
var allTriggers = ScriptApp.getProjectTriggers();
for (var i = 0; i < allTriggers.length; i++) {
var trigger = allTriggers[i];
if (trigger.getTriggerSourceId() == calendarId) {
return;
}
}
// Trigger does not exist, so create it. The trigger calls the
// 'syncEvents()' trigger function when it fires.
var trigger = ScriptApp.newTrigger('syncEvents')
.forUserCalendar(calendarId)
.onEventUpdated()
.create();
}
/**
* Sync events for the given calendar; this is the syncing trigger
* function. If a sync token already exists, this retrieves all events
* that have been modified since the last sync, then checks each to see
* if an associated conference needs to be updated and makes any required
* changes. If the sync token does not exist or is invalid, this
* retrieves future events modified in the last 24 hours instead. In
* either case, a new sync token is created and stored.
*
* @param {Object} e If called by a event updated trigger, this object
* contains the Google Calendar ID, authorization mode, and
* calling trigger ID. Only the calendar ID is actually used here,
* however.
*/
function syncEvents(e) {
var calendarId = e.calendarId;
var properties = PropertiesService.getUserProperties();
var syncToken = properties.getProperty('syncToken');
var options;
if (syncToken) {
// There's an existing sync token, so configure the following event
// retrieval request to only get events that have been modified
// since the last sync.
options = {
syncToken: syncToken
};
} else {
// No sync token, so configure to do a 'full' sync instead. In this
// example only recently updated events are retrieved in a full sync.
// A larger time window can be examined during a full sync, but this
// slows down the script execution. Consider the trade-offs while
// designing your add-on.
var now = new Date();
var yesterday = new Date();
yesterday.setDate(now.getDate() - 1);
options = {
timeMin: now.toISOString(), // Events that start after now...
updatedMin: yesterday.toISOString(), // ...and were modified recently
maxResults: 50, // Max. number of results per page of responses
orderBy: 'updated'
}
}
// Examine the list of updated events since last sync (or all events
// modified after yesterday if the sync token is missing or invalid), and
// update any associated conferences as required.
var events;
var pageToken;
do {
try {
options.pageToken = pageToken;
events = Calendar.Events.list(calendarId, options);
} catch (err) {
// Check to see if the sync token was invalidated by the server;
// if so, perform a full sync instead.
if (err.message ===
"Sync token is no longer valid, a full sync is required.") {
properties.deleteProperty('syncToken');
syncEvents(e);
return;
} else {
throw new Error(err.message);
}
}
// Read through the list of returned events looking for conferences
// to update.
if (events.items && events.items.length > 0) {
for (var i = 0; i < events.items.length; i++) {
var calEvent = events.items[i];
// Check to see if there is a record of this event has a
// conference that needs updating.
if (eventHasConference(calEvent)) {
updateConference(calEvent, calEvent.conferenceData.conferenceId);
}
}
}
pageToken = events.nextPageToken;
} while (pageToken);
// Record the new sync token.
if (events.nextSyncToken) {
properties.setProperty('syncToken', events.nextSyncToken);
}
}
/**
* Returns true if the specified event has an associated conference
* of the type managed by this add-on; retuns false otherwise.
*
* @param {Object} calEvent The Google Calendar event object, as defined by
* the Calendar API.
* @return {boolean}
*/
function eventHasConference(calEvent) {
var name = calEvent.conferenceData.conferenceSolution.name || null;
// This version checks if the conference data solution name matches the
// one of the solution names used by the add-on. Alternatively you could
// check the solution's entry point URIs or other solution-specific
// information.
if (name) {
if (name === "My Web Conference" ||
name === "My Recorded Web Conference") {
return true;
}
}
return false;
}
/**
* Update a conference based on new Google Calendar event information.
* The exact implementation of this function is highly dependant on the
* details of the third-party conferencing system, so only a rough outline
* is shown here.
*
* @param {Object} calEvent The Google Calendar event object, as defined by
* the Calendar API.
* @param {String} conferenceId The ID used to identify the conference on
* the third-party conferencing system.
*/
function updateConference(calEvent, conferenceId) {
// Check edge case: the event was cancelled
if (calEvent.status === 'cancelled') {
// Use the third-party API to delete the conference too.
} else {
// Extract any necessary information from the event object, then
// make the appropriate third-party API requests to update the
// conference with that information.
}
}
Settings.gs
/**
* Builds and returns the URL that leads to the settings page for this
* add-on.
*
* @return {String}
*/
function createSettingsUrl() {
// Returns the URL of this script's web app deployment. You
// can optionally add URL parameters here if desired.
return ScriptApp.getService().getUrl();
}
/**
* Serves HTML of the add-on setting page.
*
* @param {Object} e event parameter that can contain information
* about any URL parameters provided.
* @return {Object}
*/
function doGet(e) {
var html = HtmlService.createHtmlOutputFromFile('Settings');
return html.setTitle('My Web Conferencing Add-on Settings');
}
/**
* Extracts and returns add-on settings from the Apps Script Properties service.
* Alternatively, setttings can be stored on the third-party conferencing
* system, in which case this method should make an appropriate API call to
* retrieve them.
*
* @return {Object}
*/
function getAddonSettings() {
var props = PropertiesService.getUserProperties();
var settings = {
disableVideo: props.getProperty('disableVideo') || 'false',
namePrefix: props.getProperty('namePrefix') || ''
}
return settings;
}
/**
* Saves the specified add-on settings to the Apps Script Properties service.
* Alternatively, setttings can be stored on the third-party conferencing
* system, in which case this method should make an appropriate API call to
* store them.
*
* @param {Object} settings A collection of setting values to store.
*/
function saveAddonSettings(settings) {
var props = PropertiesService.getUserProperties();
props.setProperty('disableVideo', settings.disableVideo);
props.setProperty('namePrefix', settings.namePrefix);
}
Settings.html
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<!-- This CSS package applies Google styling. -->
<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<style>
.error {
color: #FF0000;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<h1 id="main-heading">Loading...</h1>
<div class="block" id="results">
<form name="settings-form" id="settings-form">
<div>
<label for="name-prefix">Default meeting name prefix: </label>
<input type="text" id="name-prefix" name="name-prefix">
</div>
<div>
<input type="checkbox" id="disable-video">
<label for="disable-video">
Disable all video conferencing entry points</label>
</div>
<input type="submit" name="save" id="save-button" value="Save Settings"/>
</form>
</div>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<script>
var headingText = "My Web Conference Add-on Settings";
/**
* Run initializations on web app load.
*/
$(function() {
$('#settings-form').bind('submit', onSettingsSave);
// Call the server here to retrieve any information needed to
// build the page.
google.script.run
.withSuccessHandler(function(settings) {
// Update the setting page values with the retrieved results.
updateDisplay(settings);
})
.withFailureHandler(function(msg) {
// Report failures in the settings page; any thrown messages are
// passed here as 'msg'.
$('#main-heading').text(
"Error retrieving setting information: " + msg);
$('#main-heading').addClass("error");
})
.getAddonSettings();
});
/**
* Updates display of setting information.
*
* @param {Object} settings Setting information returned by the server.
*/
function updateDisplay(settings) {
$('#main-heading').text(headingText);
$('#disable-video').prop('checked', settings.disableVideo === 'true');
$('#name-prefix').val(settings.namePrefix);
}
function onSettingsSave() {
$('#main-heading').text('Saving...');
var settings = {
disableVideo: $('#disable-video').prop('checked');
namePrefix: $('#name-prefix').val();
};
// Call the server here to save settings.
google.script.run
.withSuccessHandler(function() {
// Respond to success conditions here.
$('#main-heading').text(headingText);
})
.withFailureHandler(function(msg) {
// Report failures in the settings page; any thrown messages are
// passed here as 'msg'.
$('#main-heading').text(
"Error saving setting information: " + msg);
$('#main-heading').addClass("error");
})
.saveAddonSettings(settings);
return false;
}
</script>
</body>
</html>