diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 292075162..d3867ba55 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -23,7 +23,7 @@ jobs: concurrency: group: ${{ github.head_ref || github.ref }} cancel-in-progress: false - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v3.0.2 with: @@ -38,6 +38,6 @@ jobs: CLASP_CREDENTIALS: ${{secrets.CLASP_CREDENTIALS}} - uses: actions/setup-node@v3 with: - node-version: '14' + node-version: '20' - run: npm install -g @google/clasp - run: ./.github/scripts/clasp_push.sh ${{ steps.changed-files.outputs.all_changed_files }} diff --git a/README.md b/README.md index a56b53039..acd1c3319 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Codelab tutorials combine detailed explanation, coding exercises, and documented - [BigQuery + Sheets + Slides](http://g.co/codelabs/bigquery-sheets-slides) - [Docs Add-on + Cloud Natural Language API](http://g.co/codelabs/nlp-docs) - [Gmail Add-ons](http://g.co/codelabs/gmail-add-ons) -- [Hangouts Chat Bots](http://g.co/codelabs/chat-apps-script) +- [Google Chat Apps](https://developers.google.com/codelabs/chat-apps-script) ## Clone using the `clasp` command-line tool diff --git a/advanced/displayvideo.gs b/advanced/displayvideo.gs new file mode 100644 index 000000000..a01ac190d --- /dev/null +++ b/advanced/displayvideo.gs @@ -0,0 +1,92 @@ +/** + * Copyright Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START apps_script_dv360_list_partners] +/** + * Logs all of the partners available in the account. + */ +function listPartners() { + // Retrieve the list of available partners + try { + const partners = DisplayVideo.Partners.list(); + + if (partners.partners) { + // Print out the ID and name of each + for (let i = 0; i < partners.partners.length; i++) { + const partner = partners.partners[i]; + console.log('Found partner with ID %s and name "%s".', + partner.partnerId, partner.displayName); + } + } + } catch (e) { + // TODO (Developer) - Handle exception + console.log('Failed with error: %s', e.error); + } +} +// [END apps_script_dv360_list_partners] + +// [START apps_script_dv360_list_active_campaigns] +/** + * Logs names and ID's of all active campaigns. + * Note the use of paging tokens to retrieve the whole list. + */ +function listActiveCampaigns() { + const advertiserId = '1234567'; // Replace with your advertiser ID. + let result; + let pageToken; + try { + do { + result = DisplayVideo.Advertisers.Campaigns.list(advertiserId, { + 'filter': 'entityStatus="ENTITY_STATUS_ACTIVE"', + 'pageToken': pageToken + }); + if (result.campaigns) { + for (let i = 0; i < result.campaigns.length; i++) { + const campaign = result.campaigns[i]; + console.log('Found campaign with ID %s and name "%s".', + campaign.campaignId, campaign.displayName); + } + } + pageToken = result.nextPageToken; + } while (pageToken); + } catch (e) { + // TODO (Developer) - Handle exception + console.log('Failed with error: %s', e.error); + } +} +// [END apps_script_dv360_list_active_campaigns] + +// [START apps_script_dv360_update_line_item_name] +/** + * Updates the display name of a line item + */ +function updateLineItemName() { + const advertiserId = '1234567'; // Replace with your advertiser ID. + const lineItemId = '123456789'; //Replace with your line item ID. + const updateMask = "displayName"; + + const lineItemDef = {displayName: 'New Line Item Name (updated from Apps Script!)'}; + + try { + const lineItem = DisplayVideo.Advertisers.LineItems + .patch(lineItemDef, advertiserId, lineItemId, {updateMask:updateMask}); + + + } catch (e) { + // TODO (Developer) - Handle exception + console.log('Failed with error: %s', e.error); + } +} +// [END apps_script_dv360_update_line_item_name] diff --git a/advanced/docs.gs b/advanced/docs.gs index c8721bf5e..ee4e7e191 100644 --- a/advanced/docs.gs +++ b/advanced/docs.gs @@ -45,6 +45,20 @@ function findAndReplace(documentId, findTextToReplacementMap) { const requests = []; for (const findText in findTextToReplacementMap) { const replaceText = findTextToReplacementMap[findText]; + // One option for replacing all text is to specify all tab IDs. + const request = { + replaceAllText: { + containsText: { + text: findText, + matchCase: true + }, + replaceText: replaceText, + tabsCriteria: { + tabIds: [TAB_ID_1, TAB_ID_2, TAB_ID_3], + } + } + }; + // Another option is to omit TabsCriteria if you are replacing across all tabs. const request = { replaceAllText: { containsText: { @@ -73,8 +87,8 @@ function findAndReplace(documentId, findTextToReplacementMap) { // [START docs_insert_and_style_text] /** - * Insert text at the beginning of the document and then style the inserted - * text. + * Insert text at the beginning of the first tab in the document and then style + * the inserted text. * @param {string} documentId The document the text is inserted into. * @param {string} text The text to insert into the document. * @return {Object} replies @@ -84,7 +98,10 @@ function insertAndStyleText(documentId, text) { const requests = [{ insertText: { location: { - index: 1 + index: 1, + // A tab can be specified using its ID. When omitted, the request is + // applied to the first tab. + // tabId: TAB_ID }, text: text } @@ -119,7 +136,7 @@ function insertAndStyleText(documentId, text) { // [START docs_read_first_paragraph] /** - * Read the first paragraph of the body of a document. + * Read the first paragraph of the first tab in a document. * @param {string} documentId The ID of the document to read. * @return {Object} paragraphText * @see https://developers.google.com/docs/api/reference/rest/v1/documents/get @@ -127,8 +144,9 @@ function insertAndStyleText(documentId, text) { function readFirstParagraph(documentId) { try { // Get the document using document ID - const document = Docs.Documents.get(documentId); - const bodyElements = document.body.content; + const document = Docs.Documents.get(documentId, {'includeTabsContent': true}); + const firstTab = document.tabs[0]; + const bodyElements = firstTab.documentTab.body.content; for (let i = 0; i < bodyElements.length; i++) { const structuralElement = bodyElements[i]; // Print the first paragraph text present in document diff --git a/advanced/doubleclickbidmanager.gs b/advanced/doubleclickbidmanager.gs new file mode 100644 index 000000000..9645ad7d2 --- /dev/null +++ b/advanced/doubleclickbidmanager.gs @@ -0,0 +1,126 @@ +/** + * Copyright Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// [START apps_script_dcbm_list_queries] +/** + * Logs all of the queries available in the account. + */ +function listQueries() { + // Retrieve the list of available queries + try { + const queries = DoubleClickBidManager.Queries.list(); + + if (queries.queries) { + // Print out the ID and name of each + for (let i = 0; i < queries.queries.length; i++) { + const query = queries.queries[i]; + console.log('Found query with ID %s and name "%s".', + query.queryId, query.metadata.title); + } + } + } catch (e) { + // TODO (Developer) - Handle exception + console.log('Failed with error: %s', e.error); + } +} +// [END apps_script_dcbm_list_queries] + +// [START apps_script_dcbm_create_and_run_query] +/** + * Create and run a new DBM Query + */ +function createAndRunQuery() { + let result; + let execution; + //We leave the default date range blank for the report run to + //use the value defined during query creation + let defaultDateRange = {} + let partnerId = "1234567" //Replace with your Partner ID + let query = { + "metadata": { + "title": "Apps Script Example Report", + "dataRange": { + "range": "YEAR_TO_DATE" + }, + "format": "CSV" + }, + "params": { + "type": "STANDARD", + "groupBys": [ + "FILTER_PARTNER", + "FILTER_PARTNER_NAME", + "FILTER_ADVERTISER", + "FILTER_ADVERTISER_NAME", + ], + "filters": [ + {"type": "FILTER_PARTNER","value": partnerId} + ], + "metrics": [ + "METRIC_IMPRESSIONS" + ] + }, + "schedule": { + "frequency": "ONE_TIME" + } + } + + try { + result = DoubleClickBidManager.Queries.create(query); + if (result.queryId) { + console.log('Created query with ID %s and name "%s".', + result.queryId, result.metadata.title); + execution = DoubleClickBidManager.Queries.run(defaultDateRange, result.queryId); + if(execution.key){ + console.log('Created query report with query ID %s and report ID "%s".', + execution.key.queryId, execution.key.reportId); + } + } + } catch (e) { + // TODO (Developer) - Handle exception + console.log(e) + console.log('Failed with error: %s', e.error); + } +} +// [END apps_script_dcbm_create_and_run_query] + +// [START apps_script_dcbm_fetch_report] +/** + * Fetches a report file + */ +function fetchReport() { + const queryId = '1234567'; // Replace with your query ID. + const orderBy = "key.reportId desc"; + + try { + const report = DoubleClickBidManager.Queries.Reports.list(queryId, {orderBy:orderBy}); + if(report.reports){ + const firstReport = report.reports[0]; + if(firstReport.metadata.status.state == 'DONE'){ + const reportFile = UrlFetchApp.fetch(firstReport.metadata.googleCloudStoragePath) + console.log("Printing report content to log...") + console.log(reportFile.getContentText()) + } + else{ + console.log("Report status is %s, and is not available for download", firstReport.metadata.status.state) + } + } + + } catch (e) { + // TODO (Developer) - Handle exception + console.log(e) + console.log('Failed with error: %s', e.error); + } +} +// [END apps_script_dcbm_fetch_report] diff --git a/advanced/driveActivity.gs b/advanced/driveActivity.gs index 3d5f8bd0f..b139dfe72 100644 --- a/advanced/driveActivity.gs +++ b/advanced/driveActivity.gs @@ -19,21 +19,21 @@ * unique users that performed the activity. */ function getUsersActivity() { - var fileId = 'YOUR_FILE_ID_HERE'; + const fileId = 'YOUR_FILE_ID_HERE'; - var pageToken; - var users = {}; + let pageToken; + const users = {}; do { - var result = AppsActivity.Activities.list({ + let result = AppsActivity.Activities.list({ 'drive.fileId': fileId, 'source': 'drive.google.com', 'pageToken': pageToken }); - var activities = result.activities; - for (var i = 0; i < activities.length; i++) { - var events = activities[i].singleEvents; - for (var j = 0; j < events.length; j++) { - var event = events[j]; + const activities = result.activities; + for (let i = 0; i < activities.length; i++) { + const events = activities[i].singleEvents; + for (let j = 0; j < events.length; j++) { + const event = events[j]; users[event.user.name] = true; } } diff --git a/advanced/driveLabels.gs b/advanced/driveLabels.gs index 1e4ffa447..004a35089 100644 --- a/advanced/driveLabels.gs +++ b/advanced/driveLabels.gs @@ -69,9 +69,9 @@ function listLabelsOnDriveItem(fileId) { try { const appliedLabels = Drive.Files.listLabels(fileId); - console.log('%d label(s) are applied to this file', appliedLabels.items.length); + console.log('%d label(s) are applied to this file', appliedLabels.labels.length); - appliedLabels.items.forEach((appliedLabel) => { + appliedLabels.labels.forEach((appliedLabel) => { // Resource name of the label at the applied revision. const labelName = 'labels/' + appliedLabel.id + '@' + appliedLabel.revisionId; diff --git a/advanced/gmail.gs b/advanced/gmail.gs index a1195c6b7..ec08431bf 100644 --- a/advanced/gmail.gs +++ b/advanced/gmail.gs @@ -46,7 +46,7 @@ function listInboxSnippets() { pageToken: pageToken }); if (threadList.threads && threadList.threads.length > 0) { - threadList.threads.forEach(function(thread) { + threadList.threads.forEach(function (thread) { console.log('Snippet: %s', thread.snippet); }); } @@ -90,8 +90,8 @@ function logRecentHistory() { }); const history = recordList.history; if (history && history.length > 0) { - history.forEach(function(record) { - record.messages.forEach(function(message) { + history.forEach(function (record) { + record.messages.forEach(function (message) { if (changed.indexOf(message.id) === -1) { changed.push(message.id); } @@ -101,7 +101,7 @@ function logRecentHistory() { pageToken = recordList.nextPageToken; } while (pageToken); - changed.forEach(function(id) { + changed.forEach(function (id) { console.log('Message Changed: %s', id); }); } catch (err) { @@ -130,3 +130,33 @@ function getRawMessage() { } } // [END gmail_raw] + +// [START gmail_list_messages] +/** + * Lists unread messages in the user's inbox using the advanced Gmail service. + */ +function listMessages() { + // The special value 'me' indicates the authenticated user. + const userId = 'me'; + + // Define optional parameters for the request. + const options = { + maxResults: 10, // Limit the number of messages returned. + q: 'is:unread', // Search for unread messages. + }; + + try { + // Call the Gmail.Users.Messages.list method. + const response = Gmail.Users.Messages.list(userId, options); + const messages = response.messages; + console.log('Unread Messages:'); + + for (const message of messages) { + console.log(`- Message ID: ${message.id}`); + } + } catch (err) { + // Log any errors to the Apps Script execution log. + console.log(`Failed with error: ${err.message}`); + } +} +// [END gmail_list_messages] diff --git a/advanced/iot.gs b/advanced/iot.gs index 8087fd023..4f9739183 100644 --- a/advanced/iot.gs +++ b/advanced/iot.gs @@ -18,12 +18,12 @@ * Lists the registries for the configured project and region. */ function listRegistries() { - console.log(response); - var projectId = 'your-project-id'; - var cloudRegion = 'us-central1'; - var parent = 'projects/' + projectId + '/locations/' + cloudRegion; + const projectId = 'your-project-id'; + const cloudRegion = 'us-central1'; + const parent = 'projects/' + projectId + '/locations/' + cloudRegion; - var response = CloudIoT.Projects.Locations.Registries.list(parent); + const response = CloudIoT.Projects.Locations.Registries.list(parent); + console.log(response); if (response.deviceRegistries) { response.deviceRegistries.forEach( function(registry) { @@ -38,23 +38,23 @@ function listRegistries() { * Creates a registry. */ function createRegistry() { - var cloudRegion = 'us-central1'; - var name = 'your-registry-name'; - var projectId = 'your-project-id'; - var topic = 'your-pubsub-topic'; + const cloudRegion = 'us-central1'; + const name = 'your-registry-name'; + const projectId = 'your-project-id'; + const topic = 'your-pubsub-topic'; - var pubsubTopic = 'projects/' + projectId + '/topics/' + topic; + const pubsubTopic = 'projects/' + projectId + '/topics/' + topic; - var registry = { + const registry = { 'eventNotificationConfigs': [{ // From - https://console.cloud.google.com/cloudpubsub pubsubTopicName: pubsubTopic }], 'id': name }; - var parent = 'projects/' + projectId + '/locations/' + cloudRegion; + const parent = 'projects/' + projectId + '/locations/' + cloudRegion; - var response = CloudIoT.Projects.Locations.Registries.create(registry, parent); + const response = CloudIoT.Projects.Locations.Registries.create(registry, parent); console.log('Created registry: ' + response.id); } // [END apps_script_iot_create_registry] @@ -64,14 +64,14 @@ function createRegistry() { * Describes a registry. */ function getRegistry() { - var cloudRegion = 'us-central1'; - var name = 'your-registry-name'; - var projectId = 'your-project-id'; + const cloudRegion = 'us-central1'; + const name = 'your-registry-name'; + const projectId = 'your-project-id'; - var parent = 'projects/' + projectId + '/locations/' + cloudRegion; - var registryName = parent + '/registries/' + name; + const parent = 'projects/' + projectId + '/locations/' + cloudRegion; + const registryName = parent + '/registries/' + name; - var response = CloudIoT.Projects.Locations.Registries.get(registryName); + const response = CloudIoT.Projects.Locations.Registries.get(registryName); console.log('Retrieved registry: ' + response.id); } // [END apps_script_iot_get_registry] @@ -81,14 +81,14 @@ function getRegistry() { * Deletes a registry. */ function deleteRegistry() { - var cloudRegion = 'us-central1'; - var name = 'your-registry-name'; - var projectId = 'your-project-id'; + const cloudRegion = 'us-central1'; + const name = 'your-registry-name'; + const projectId = 'your-project-id'; - var parent = 'projects/' + projectId + '/locations/' + cloudRegion; - var registryName = parent + '/registries/' + name; + const parent = 'projects/' + projectId + '/locations/' + cloudRegion; + const registryName = parent + '/registries/' + name; - var response = CloudIoT.Projects.Locations.Registries.remove(registryName); + const response = CloudIoT.Projects.Locations.Registries.remove(registryName); // Successfully removed registry if exception was not thrown. console.log('Deleted registry: ' + name); } @@ -99,14 +99,14 @@ function deleteRegistry() { * Lists the devices in the given registry. */ function listDevicesForRegistry() { - var cloudRegion = 'us-central1'; - var name = 'your-registry-name'; - var projectId = 'your-project-id'; + const cloudRegion = 'us-central1'; + const name = 'your-registry-name'; + const projectId = 'your-project-id'; - var parent = 'projects/' + projectId + '/locations/' + cloudRegion; - var registryName = parent + '/registries/' + name; + const parent = 'projects/' + projectId + '/locations/' + cloudRegion; + const registryName = parent + '/registries/' + name; - var response = CloudIoT.Projects.Locations.Registries.Devices.list(registryName); + const response = CloudIoT.Projects.Locations.Registries.Devices.list(registryName); console.log('Registry contains the following devices: '); if (response.devices) { @@ -123,15 +123,15 @@ function listDevicesForRegistry() { * Creates a device without credentials. */ function createDevice() { - var cloudRegion = 'us-central1'; - var name = 'your-device-name'; - var projectId = 'your-project-id'; - var registry = 'your-registry-name'; + const cloudRegion = 'us-central1'; + const name = 'your-device-name'; + const projectId = 'your-project-id'; + const registry = 'your-registry-name'; console.log('Creating device: ' + name + ' in Registry: ' + registry); - var parent = 'projects/' + projectId + '/locations/' + cloudRegion + '/registries/' + registry; + const parent = 'projects/' + projectId + '/locations/' + cloudRegion + '/registries/' + registry; - var device = { + const device = { id: name, gatewayConfig: { gatewayType: 'NON_GATEWAY', @@ -139,7 +139,7 @@ function createDevice() { } }; - var response = CloudIoT.Projects.Locations.Registries.Devices.create(device, parent); + const response = CloudIoT.Projects.Locations.Registries.Devices.create(device, parent); console.log('Created device:' + response.name); } // [END apps_script_iot_create_unauth_device] @@ -153,21 +153,21 @@ function createRsaDevice() { // openssl req -x509 -newkey rsa:2048 -days 3650 -keyout rsa_private.pem \ // -nodes -out rsa_cert.pem -subj "/CN=unused" // - // **NOTE** Be sure to insert the newline charaters in the string varant. - var cert = + // **NOTE** Be sure to insert the newline charaters in the string constant. + const cert = '-----BEGIN CERTIFICATE-----\n' + 'your-PUBLIC-certificate-b64-bytes\n' + '...\n' + 'more-PUBLIC-certificate-b64-bytes==\n' + '-----END CERTIFICATE-----\n'; - var cloudRegion = 'us-central1'; - var name = 'your-device-name'; - var projectId = 'your-project-id'; - var registry = 'your-registry-name'; + const cloudRegion = 'us-central1'; + const name = 'your-device-name'; + const projectId = 'your-project-id'; + const registry = 'your-registry-name'; - var parent = 'projects/' + projectId + '/locations/' + cloudRegion + '/registries/' + registry; - var device = { + const parent = 'projects/' + projectId + '/locations/' + cloudRegion + '/registries/' + registry; + const device = { id: name, gatewayConfig: { gatewayType: 'NON_GATEWAY', @@ -181,7 +181,7 @@ function createRsaDevice() { }] }; - var response = CloudIoT.Projects.Locations.Registries.Devices.create(device, parent); + const response = CloudIoT.Projects.Locations.Registries.Devices.create(device, parent); console.log('Created device:' + response.name); } // [END apps_script_iot_create_rsa_device] @@ -191,15 +191,15 @@ function createRsaDevice() { * Deletes a device from the given registry. */ function deleteDevice() { - var cloudRegion = 'us-central1'; - var name = 'your-device-name'; - var projectId = 'your-project-id'; - var registry = 'your-registry-name'; + const cloudRegion = 'us-central1'; + const name = 'your-device-name'; + const projectId = 'your-project-id'; + const registry = 'your-registry-name'; - var parent = 'projects/' + projectId + '/locations/' + cloudRegion + '/registries/' + registry; - var deviceName = parent + '/devices/' + name; + const parent = 'projects/' + projectId + '/locations/' + cloudRegion + '/registries/' + registry; + const deviceName = parent + '/devices/' + name; - var response = CloudIoT.Projects.Locations.Registries.Devices.remove(deviceName); + const response = CloudIoT.Projects.Locations.Registries.Devices.remove(deviceName); // If no exception thrown, device was successfully removed console.log('Successfully deleted device: ' + deviceName); } diff --git a/advanced/test_displayvideo.gs b/advanced/test_displayvideo.gs new file mode 100644 index 000000000..b7a454cab --- /dev/null +++ b/advanced/test_displayvideo.gs @@ -0,0 +1,48 @@ +/** + * Copyright Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Tests listPartners function of displayvideo.gs + */ +function itShouldListPartners() { + console.log('> itShouldListPartners'); + listPartners(); +} + +/** + * Tests listActiveCampaigns function of displayvideo.gs + */ +function itShouldListActiveCampaigns() { + console.log('> itShouldListActiveCampaigns'); + listActiveCampaigns(); +} + +/** + * Tests updateLineItemName function of displayvideo.gs + */ +function itShouldUpdateLineItemName() { + console.log('> itShouldUpdateLineItemName'); + updateLineItemName(); +} + +/** + * Run all tests + */ +function RUN_ALL_TESTS() { + itShouldListPartners(); + itShouldListActiveCampaigns(); + itShouldUpdateLineItemName(); +} diff --git a/advanced/test_doubleclickbidmanager.gs b/advanced/test_doubleclickbidmanager.gs new file mode 100644 index 000000000..fd2bc596f --- /dev/null +++ b/advanced/test_doubleclickbidmanager.gs @@ -0,0 +1,48 @@ +/** + * Copyright Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Tests listQueries function of doubleclickbidmanager.gs + */ +function itShouldListQueries() { + console.log('> itShouldListQueries'); + listQueries(); +} + +/** + * Tests createAndRunQuery function of doubleclickbidmanager.gs + */ +function itShouldCreateAndRunQuery() { + console.log('> itShouldCreateAndRunQuery'); + createAndRunQuery(); +} + +/** + * Tests fetchReport function of doubleclickbidmanager.gs + */ +function itShouldFetchReport() { + console.log('> itShouldFetchReport'); + fetchReport(); +} + +/** + * Run all tests + */ +function RUN_ALL_TESTS() { + itShouldListQueries(); + itShouldCreateAndRunQuery(); + itShouldFetchReport(); +} diff --git a/ai/custom-func-ai-agent/AiVertex.js b/ai/custom-func-ai-agent/AiVertex.js new file mode 100644 index 000000000..aa58d0836 --- /dev/null +++ b/ai/custom-func-ai-agent/AiVertex.js @@ -0,0 +1,111 @@ +/* +Copyright 2025 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const LOCATION = PropertiesService.getScriptProperties().getProperty('LOCATION'); +const GEMINI_MODEL_ID = PropertiesService.getScriptProperties().getProperty('GEMINI_MODEL_ID'); +const REASONING_ENGINE_ID = PropertiesService.getScriptProperties().getProperty('REASONING_ENGINE_ID'); +const SERVICE_ACCOUNT_KEY = PropertiesService.getScriptProperties().getProperty('SERVICE_ACCOUNT_KEY'); + +const credentials = credentialsForVertexAI(); + +/** + * @param {string} statement The statement to fact-check. + */ +function requestLlmAuditorAdkAiAgent(statement) { + return UrlFetchApp.fetch( + `https://${LOCATION}-aiplatform.googleapis.com/v1/projects/${credentials.projectId}/locations/${LOCATION}/reasoningEngines/${REASONING_ENGINE_ID}:streamQuery?alt=sse`, + { + method: 'post', + headers: { 'Authorization': `Bearer ${credentials.accessToken}` }, + contentType: 'application/json', + muteHttpExceptions: true, + payload: JSON.stringify({ + "class_method": "async_stream_query", + "input": { + "user_id": "google_sheets_custom_function_fact_check", + "message": statement, + } + }) + } + ).getContentText(); +} + +/** + * @param {string} prompt The Gemini prompt to use. + */ +function requestOutputFormatting(prompt) { + const response = UrlFetchApp.fetch( + `https://${LOCATION}-aiplatform.googleapis.com/v1/projects/${credentials.projectId}/locations/${LOCATION}/publishers/google/models/${GEMINI_MODEL_ID}:generateContent`, + { + method: 'post', + headers: { 'Authorization': `Bearer ${credentials.accessToken}` }, + contentType: 'application/json', + muteHttpExceptions: true, + payload: JSON.stringify({ + "contents": [{ + "role": "user", + "parts": [{ "text": prompt }] + }], + "generationConfig": { "temperature": 0.1, "maxOutputTokens": 2048 }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_NONE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_NONE" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_NONE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_NONE" + } + ] + }) + } + ); + return JSON.parse(response).candidates[0].content.parts[0].text +} + +/** + * Gets credentials required to call Vertex API using a Service Account. + * Requires use of Service Account Key stored with project. + * + * @return {!Object} Containing the Google Cloud project ID and the access token. + */ +function credentialsForVertexAI() { + const credentials = SERVICE_ACCOUNT_KEY; + if (!credentials) { + throw new Error("service_account_key script property must be set."); + } + + const parsedCredentials = JSON.parse(credentials); + + const service = OAuth2.createService("Vertex") + .setTokenUrl('https://oauth2.googleapis.com/token') + .setPrivateKey(parsedCredentials['private_key']) + .setIssuer(parsedCredentials['client_email']) + .setPropertyStore(PropertiesService.getScriptProperties()) + .setScope("https://www.googleapis.com/auth/cloud-platform"); + return { + projectId: parsedCredentials['project_id'], + accessToken: service.getAccessToken(), + } +} diff --git a/ai/custom-func-ai-agent/Code.js b/ai/custom-func-ai-agent/Code.js new file mode 100644 index 000000000..d18674f6a --- /dev/null +++ b/ai/custom-func-ai-agent/Code.js @@ -0,0 +1,36 @@ +/* +Copyright 2025 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Passes a statement to fact-check and, optionally, output formatting instructions. + * + * @param {string} statement The statement to fact-check as a string or single cell + * reference (data ranges are not supported). + * @param {string} outputFormat The instructions as a string or single cell reference + * (data ranges are not supported). + * + * @return The generated and formatted verdict + * @customfunction + */ +function FACT_CHECK(statement, outputFormat) { + if (!outputFormat || outputFormat == "") { + outputFormat = 'Summarize it. Only keep the verdict result and main arguments. ' + + 'Do not reiterate the fact being checked. Remove all markdown. ' + + 'State the verdit result in a first paragraph in a few words and the rest of the summary in a second paragraph.'; + } + + return requestOutputFormatting(`Here is a fact checking result: ${requestLlmAuditorAdkAiAgent(statement)}.\n\n${outputFormat}`); +} diff --git a/ai/custom-func-ai-agent/README.md b/ai/custom-func-ai-agent/README.md new file mode 100644 index 000000000..85a16c5a4 --- /dev/null +++ b/ai/custom-func-ai-agent/README.md @@ -0,0 +1,43 @@ +# Google Sheets Custom Function relying on ADK AI Agent and Gemini model + +A [Vertex AI](https://cloud.google.com/vertex-ai) agent-powered **fact checker** custom function for Google Sheets to be used as a bound Apps Script project. + +![](./images/showcase.png) + +## Tutorial + +For detailed instructions to deploy and run this sample, follow the +[dedicated tutorial](https://developers.google.com/apps-script/samples/custom-functions/fact-check). + +## Overview + +The **Google Sheets custom function** named `FACT_CHECK` integrates the sophisticated, multi-tool, multi-step reasoning capabilities of a **Vertex AI Agent Engine (ADK Agent)** directly into your Google Sheets spreadsheets. + +It operates as an end-to-end solution. It analyzes a statement, grounds its response using the latest web information, and returns the result in the format you need: + + * Usage: `=FACT_CHECK("Your statement here")` for a concise and summarized output. `=FACT_CHECK("Your statement here", "Your output formatting instructions here")` for a specific output format. + * Reasoning: [**LLM Auditor ADK AI Agent (Python sample)**](https://github.com/google/adk-samples/tree/main/python/agents/llm-auditor). + * Output formatting: [**Gemini model**](https://cloud.google.com/vertex-ai/generative-ai/docs/models). + +## Prerequisites + +* Google Cloud Project with billing enabled. + +## Set up your environment + +1. Configure the Google Cloud project + 1. Enable the Vertex AI API + 1. Create a Service Account and grant the role `Vertex AI User` + 1. Create a private key with type JSON. This will download the JSON file. +1. Setup, install, and deploy the LLM Auditor ADK AI Agent sample + 1. Use Vertex AI + 1. Use the same Google Cloud project + 1. Use the location `us-central1` + 1. Use the Vertex AI Agent Engine +1. Open an Apps Script project bound to a Google Sheets spreadsheet + 1. Add a Script Property. Enter `LOCATION` as the property name and `us-central1` as the value. + 1. Add a Script Property. Enter `GEMINI_MODEL_ID` as the property name and `gemini-2.5-flash-lite` as the value. + 1. Add a Script Property. Enter `REASONING_ENGINE_ID` as the property name and the ID of the deployed LLM Auditor ADK AI Agent as the value. + 1. Add a Script Property. Enter `SERVICE_ACCOUNT_KEY` as the property name and paste the JSON key from the service account as the value. + 1. Add OAuth2 v43 Apps Script Library using the ID `1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF` + 1. Set the script files `Code.gs` and `AiVertex.gs` in the Apps Script project with the JS file contents in this project diff --git a/ai/custom-func-ai-agent/appsscript.json b/ai/custom-func-ai-agent/appsscript.json new file mode 100644 index 000000000..d1a41c7c1 --- /dev/null +++ b/ai/custom-func-ai-agent/appsscript.json @@ -0,0 +1,12 @@ +{ + "timeZone": "America/Los_Angeles", + "dependencies": { + "libraries": [{ + "userSymbol": "OAuth2", + "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF", + "version": "43" + }] + }, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8" +} \ No newline at end of file diff --git a/ai/custom-func-ai-agent/images/showcase.png b/ai/custom-func-ai-agent/images/showcase.png new file mode 100644 index 000000000..4b0314f15 Binary files /dev/null and b/ai/custom-func-ai-agent/images/showcase.png differ diff --git a/ai/email-classifier/Cards.gs b/ai/email-classifier/Cards.gs new file mode 100644 index 000000000..55ffe0ea4 --- /dev/null +++ b/ai/email-classifier/Cards.gs @@ -0,0 +1,182 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Triggered when the add-on is opened from the Gmail homepage. + * + * @param {!Object} e - The event object. + * @returns {!Card} - The homepage card. + */ +function onHomepageTrigger(e) { + return buildHomepageCard(); +} + +/** + * Builds the main card displayed on the Gmail homepage. + * + * @returns {!Card} - The homepage card. + */ +function buildHomepageCard() { + // Create a new card builder + const cardBuilder = CardService.newCardBuilder(); + + // Create a card header + const cardHeader = CardService.newCardHeader(); + cardHeader.setImageUrl('https://fonts.gstatic.com/s/i/googlematerialicons/label_important/v20/googblue-24dp/1x/gm_label_important_googblue_24dp.png'); + cardHeader.setImageStyle(CardService.ImageStyle.CIRCLE); + cardHeader.setTitle("Email Classifier"); + + // Add the header to the card + cardBuilder.setHeader(cardHeader); + + // Create a card section + const cardSection = CardService.newCardSection(); + + // Create buttons for generating sample emails and analyzing sentiment + const buttonSet = CardService.newButtonSet(); + + // Create "Classify emails" button + const classifyButton = createFilledButton({ + text: 'Classify emails', + functionName: 'main', + color: '#007bff', + icon: 'new_label' + }); + buttonSet.addButton(classifyButton); + + // Create "Create Labels" button + const createLabelsButtton = createFilledButton({ + text: 'Create labels', + functionName: 'createLabels', + color: '#34A853', + icon: 'add' + }); + + // Create "Remove Labels" button + const removeLabelsButtton = createFilledButton({ + text: 'Remove labels', + functionName: 'removeLabels', + color: '#FF0000', + icon: 'delete' + }); + + if (labelsCreated()) { + buttonSet.addButton(removeLabelsButtton); + } else { + buttonSet.addButton(createLabelsButtton); + } + + // Add the button set to the section + cardSection.addWidget(buttonSet); + + // Add the section to the card + cardBuilder.addSection(cardSection); + + // Build and return the card + return cardBuilder.build(); +} + +/** + * Creates a filled text button with the specified text, function, and color. + * + * @param {{text: string, functionName: string, color: string, icon: string}} options + * - text: The text to display on the button. + * - functionName: The name of the function to call when the button is clicked. + * - color: The background color of the button. + * - icon: The material icon to display on the button. + * @returns {!TextButton} - The created text button. + */ +function createFilledButton({text, functionName, color, icon}) { + // Create a new text button + const textButton = CardService.newTextButton(); + + // Set the button text + textButton.setText(text); + + // Set the action to perform when the button is clicked + const action = CardService.newAction(); + action.setFunctionName(functionName); + action.setLoadIndicator(CardService.LoadIndicator.SPINNER); + textButton.setOnClickAction(action); + + // Set the button style to filled + textButton.setTextButtonStyle(CardService.TextButtonStyle.FILLED); + + // Set the background color + textButton.setBackgroundColor(color); + + textButton.setMaterialIcon(CardService.newMaterialIcon().setName(icon)); + + return textButton; +} + +/** + * Creates a notification response with the specified text. + * + * @param {string} notificationText - The text to display in the notification. + * @returns {!ActionResponse} - The created action response. + */ +function buildNotificationResponse(notificationText) { + // Create a new notification + const notification = CardService.newNotification(); + notification.setText(notificationText); + + // Create a new action response builder + const actionResponseBuilder = CardService.newActionResponseBuilder(); + + // Set the notification for the action response + actionResponseBuilder.setNotification(notification); + + // Build and return the action response + return actionResponseBuilder.build(); +} + +/** + * Creates a card to display the spreadsheet link. + * + * @param {string} spreadsheetUrl - The URL of the spreadsheet. + * @returns {!ActionResponse} - The created action response. + */ +function showSpreadsheetLink(spreadsheetUrl) { + const updatedCardBuilder = CardService.newCardBuilder(); + + updatedCardBuilder.setHeader(CardService.newCardHeader().setTitle('Sheet generated!')); + + const updatedSection = CardService.newCardSection() + .addWidget(CardService.newTextParagraph() + .setText('Click to open the sheet:') + ) + .addWidget(CardService.newTextButton() + .setText('Open Sheet') + .setOpenLink(CardService.newOpenLink() + .setUrl(spreadsheetUrl) + .setOpenAs(CardService.OpenAs.FULL_SCREEN) // Opens in a new browser tab/window + .setOnClose(CardService.OnClose.NOTHING) // Does nothing when the tab is closed + ) + ) + .addWidget(CardService.newTextButton() // Optional: Add a button to go back or refresh + .setText('Go Back') + .setOnClickAction(CardService.newAction() + .setFunctionName('onHomepageTrigger')) // Go back to the initial state + ); + + updatedCardBuilder.addSection(updatedSection); + const newNavigation = CardService.newNavigation().updateCard(updatedCardBuilder.build()); + + return CardService.newActionResponseBuilder() + .setNavigation(newNavigation) // This updates the current card in the UI + .build(); +} diff --git a/ai/email-classifier/ClassifyEmail.gs b/ai/email-classifier/ClassifyEmail.gs new file mode 100644 index 000000000..cda44a7ce --- /dev/null +++ b/ai/email-classifier/ClassifyEmail.gs @@ -0,0 +1,133 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Constructs the prompt for classifying an email. + * + * @param {string} subject The subject of the email. + * @param {string} body The body of the email. + * @returns {string} The prompt for classifying an email. + */ +const classifyEmailPrompt = (subject, body) => ` +Objective: You are an AI assistant tasked with classifying email threads. Analyze the entire email thread provided below and determine the single most appropriate classification label. Your response must conform to the provided schema. + +**Classification Labels & Descriptions:** + +* **needs-response**: The sender is explicitly or implicitly expecting a **direct, communicative reply** from me (${ME}) to answer a question, acknowledge receipt of information, confirm understanding, or continue a conversation. **Prioritize this label if the core expectation is purely a written or verbal communication back to the sender.** +* **action-required**: The email thread requires me (${ME}) to perform a **distinct task, make a formal decision, provide a review leading to approval/rejection, or initiate a process that results in a demonstrable change or outcome.** This label is for actions *beyond* just sending a reply, such as completing a document, setting up a meeting, approving a request, delegating a task, or performing a delegated duty. +* **for-your-info**: The email thread's primary purpose is to convey information, updates, or announcements. No immediate action or direct reply is expected or required from me (${ME}); the main purpose is for me to be informed and aware. This includes both routine 'FYI' updates and critical announcements where my role is to comprehend, not act or respond. + +**Evaluation Criteria - Consider the following:** + +* **Sender's Intent & My Role:** What does the sender want me (${ME}) to do, say, or know? +* **Direct Requests:** Are there explicit questions or calls to action addressed to me (${ME})? +* **Distinguishing Action vs. Response:** + * If the email primarily asks for a *verbal or written communication* (e.g., answering a specific question, providing feedback, confirming receipt, giving thoughts, and is directly addressed to me (${ME})), it's likely \`needs-response\`. + * If the email requires me to *perform a specific task or make a formal decision that goes beyond simply communicating* (e.g., completing a document, scheduling, approving a request, delegating, implementing a change), it's likely \`action-required\`. +* **Urgency/Deadlines:** Are there time-sensitive elements mentioned? +* **Last Message Focus:** Give slightly more weight to the content of the most recent messages in the thread. +* **Keywords:** + * Look for terms like "answer," "reply to," "your thoughts on," "confirm," "acknowledge" for \`needs-response\`. + * Look for terms like "complete," "approve," "review and approve," "sign," "process," "set up," "delegate" for \`action-required\`. + * Look for terms like "FYI," "update," "announcement," "read," "info" for \`for-information\`. +* **Overall Significance:** Is the topic critical or routine, influencing the *type* of information being conveyed? + +**Input:** Email message content +Subject: ${subject} + +--- Email Thread Messages --- +${body} +--- End of Email Thread --- + +**Output:** Return the single best classification and a brief justification. +Format: JSON object with '[Classification]', and '[Reason]' +`.trim(); + +/** + * Classifies an email based on its subject and messages. + * + * @param {string} subject The subject of the email. + * @param {!Array} messages An array of Gmail messages. + * @returns {!Object} The classification object. + */ +function classifyEmail(subject, messages) { + const body = []; + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + body.push(`Message ${i + 1}:`); + body.push(`From: ${message.getFrom()}`); + body.push(`To:${message.getTo()}`); + body.push('Body:'); + body.push(message.getPlainBody()); + body.push('---'); + } + + // Prepare the request payload + const payload = { + contents: [ + { + role: "user", + parts: [ + { + text: classifyEmailPrompt(subject, body.join('\n')) + } + ] + } + ], + generationConfig: { + temperature: 0, + topK: 1, + topP: 0.1, + seed: 37, + maxOutputTokens: 1024, + responseMimeType: "application/json", + // Expected response format for simpler parsing. + responseSchema: { + type: "object", + properties: { + classification: { + type: "string", + enum: Object.keys(classificationLabels), + }, + reason: { + type: 'string' + } + } + } + } + }; + + // Prepare the request options + const options = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${ScriptApp.getOAuthToken()}` + }, + contentType: 'application/json', + muteHttpExceptions: true, // Set to true to inspect the error response + payload: JSON.stringify(payload) + }; + + // Make the API request + const response = UrlFetchApp.fetch(API_URL, options); + + // Parse the response. There are two levels of JSON responses to parse. + const parsedResponse = JSON.parse(response.getContentText()); + const text = parsedResponse.candidates[0].content.parts[0].text; + const classification = JSON.parse(text); + return classification; +} + diff --git a/ai/email-classifier/Code.gs b/ai/email-classifier/Code.gs new file mode 100644 index 000000000..c802e8192 --- /dev/null +++ b/ai/email-classifier/Code.gs @@ -0,0 +1,67 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Main function to process emails, classify them, and update a spreadsheet. + * This function searches for unread emails in the inbox from the last 7 days, + * classifies them based on their subject and content, adds labels to the emails, + * creates draft responses for emails that need a response, and logs the + * classification results in a spreadsheet. + * @return {string} The URL of the spreadsheet. + */ +function main() { + // Calculate the date 7 days ago + const today = new Date(); + const sevenDaysAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); + + // Create a Sheet + const headers = ['Subject', 'Classification', 'Reason']; + const spreadsheet = createSheetWithHeaders(headers); + + // Format the date for the Gmail search query (YYYY/MM/DD) + // Using Utilities.formatDate ensures correct formatting based on script + // timezone + const formattedDate = Utilities.formatDate( + sevenDaysAgo, Session.getScriptTimeZone(), 'yyyy/MM/dd'); + + // Construct the search query + const query = `is:unread after:${formattedDate} in:inbox`; + console.log('Searching for emails with query: ' + query); + + // Search for threads matching the query + // Note: GmailApp.search() returns threads where *at least one* message + // matches + const threads = GmailApp.search(query); + createLabels(); + + for (const thread of threads) { + const messages = thread.getMessages(); + const subject = thread.getFirstMessageSubject(); + const {classification, reason} = classifyEmail(subject, messages); + console.log(`Classification: ${classification}, Reason: ${reason}`); + + thread.addLabel(classificationLabels[classification].gmailLabel); + + if (classification === 'needs-response') { + const draft = draftEmail(subject, messages); + thread.createDraftReplyAll(null, {htmlBody: draft}); + } + + addDataToSheet(spreadsheet, hyperlink(thread), classification, reason); + } + + return showSpreadsheetLink(spreadsheet.getUrl()); +} diff --git a/ai/email-classifier/Constants.gs b/ai/email-classifier/Constants.gs new file mode 100644 index 000000000..2908b83ba --- /dev/null +++ b/ai/email-classifier/Constants.gs @@ -0,0 +1,23 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const PROJECT_ID = ''; +const LOCATION = 'us-central1'; +const API_ENDPOINT = `${LOCATION}-aiplatform.googleapis.com`; +const MODEL = 'gemini-2.5-pro-preview-05-06'; +const GENERATE_CONTENT_API = 'generateContent'; +const API_URL = `https://${API_ENDPOINT}/v1/projects/${PROJECT_ID}/locations/${LOCATION}/publishers/google/models/${MODEL}:${GENERATE_CONTENT_API}`; +const ME = ''; diff --git a/ai/email-classifier/DraftEmail.gs b/ai/email-classifier/DraftEmail.gs new file mode 100644 index 000000000..f016386ba --- /dev/null +++ b/ai/email-classifier/DraftEmail.gs @@ -0,0 +1,141 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Constructs a prompt for drafting an email. + * + * @param {string} subject The subject of the email thread. + * @param {string} body The body of the email thread. + * @returns {string} The prompt string. + */ +const draftEmailPrompt = (subject, body) => ` +You are an AI assistant. Based on the following email thread: + +Subject: ${subject} + +--- Email Thread Messages --- +${body} +--- End of Email Thread --- + +Task: Considering all messages in this thread: +- Help me ${ME} draft a polite and professional reply that addresses the key points from the most recent message(s) in HTML +- Do NOT include subject of the email + +Draft Criteria: Consider the following: +* Explicit Questions: Are there direct questions posed to me ${ME}, especially in the most recent messages? +* Calls to Action: Are there clear instructions or requests for the me ${ME}, to *do* something? +* Urgency/Deadlines: Does the thread mention deadlines or urgent requests? +* Sender's Intent: What does the sender seem to want? +* My Role: What am I (${ME}) being asked to do or know? +* Keywords: Look for terms like "please," "urgent," "FYI," "question," "task," "review," "approve," "respond," "deadline." +* Last Message Focus: Give slightly more weight to the most recent messages. +* Overall Significance: Is the topic critical or routine? + +Output: Return the draft message in HTML format. +Format: HTML + +Example format: + + + + My Email + + +

Hello, World!

+

This is an HTML email sent from Google Apps Script.

+ + +`.trim(); + +/** + * Drafts an email based on the given subject and messages. + * + * @param {string} subject The subject of the email thread. + * @param {!Array} messages An array of Gmail messages. + * @returns {string|null} The drafted email in HTML format or null if not found. + */ +function draftEmail(subject, messages) { + const body = []; + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + body.push(`Message ${i + 1}:`); + body.push(`From: ${message.getFrom()}`); + body.push(`To:${message.getTo()}`); + body.push('Body:'); + body.push(message.getPlainBody()); + body.push('---'); + } + + // Prepare the request payload + const payload = { + contents: [ + { + role: "user", + parts: [ + { + text: draftEmailPrompt(subject, body.join('\n')) + } + ] + } + ], + generationConfig: { + temperature: 0, + topK: 1, + topP: 0.1, + seed: 37, + maxOutputTokens: 1024, + responseMimeType: 'text/plain' + } + }; + + // Prepare the request options + const options = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${ScriptApp.getOAuthToken()}` + }, + contentType: 'application/json', + muteHttpExceptions: true, // Set to true to inspect the error response + payload: JSON.stringify(payload) + }; + + // Make the API request + const response = UrlFetchApp.fetch(API_URL, options); + + // Parse the response. There are two levels of JSON responses to parse. + const parsedResponse = JSON.parse(response.getContentText()); + const draft = parsedResponse.candidates[0].content.parts[0].text; + return extractHtmlContent(draft); +} + +/** + * Extracts HTML content from a string. + * + * @param {string} textString The string to extract HTML content from. + * @returns {string|null} The HTML content or null if not found. + */ +function extractHtmlContent(textString) { + // The regex pattern: + // ````html` (literal start marker) + // `(.*?)` (capturing group for any character, non-greedily, including newlines) + // ` ``` ` (literal end marker) + // `s` flag makes '.' match any character including newlines. + const match = textString.match(/```html(.*?)```/s); + if (match && match[1]) { + return match[1]; // Return the content of the first capturing group + } + return null; // Or an empty string, depending on desired behavior if not found +} diff --git a/ai/email-classifier/Labels.gs b/ai/email-classifier/Labels.gs new file mode 100644 index 000000000..4f6505ad3 --- /dev/null +++ b/ai/email-classifier/Labels.gs @@ -0,0 +1,108 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const classificationLabels = { + "action-required": { + "name": "🚨 Action Required", + "textColor": '#ffffff', + "backgroundColor": "#1c4587" + }, + "needs-response": { + "name": "â†Ēī¸ Needs Response", + "textColor": '#ffffff', + "backgroundColor": "#16a765" + }, + "for-your-info": { + "name": "â„šī¸ For Your Info", + "textColor": '#000000', + "backgroundColor": "#fad165" + }, +}; + +/** + * Creates Gmail labels based on the classification labels defined in the `classificationLabels` object. + * If a label already exists, it updates the color. Otherwise, it creates a new label. + * After creating or updating labels, it logs a message to the console and returns the homepage card. + * @returns {!CardService.Card} The homepage card. + */ +function createLabels() { + for (const labelName in classificationLabels) { + const classificationLabel = classificationLabels[labelName]; + const { name, textColor, backgroundColor } = classificationLabel; + let gmailLabel = GmailApp.getUserLabelByName(name); + + if (!gmailLabel) { + gmailLabel = GmailApp.createLabel(name); + Gmail.Users.Labels.update({ + name: name, + color: { + textColor: textColor, + backgroundColor: backgroundColor + } + }, 'me', fetchLabelId(name)); + } + + classificationLabel.gmailLabel = gmailLabel; + } + + console.log('Labels created.'); + return buildHomepageCard(); +} + +/** + * Checks if all classification labels exist in Gmail. + * @returns {boolean} True if all labels exist, false otherwise. + */ +function labelsCreated() { + for (const labelName in classificationLabels) { + const { name } = classificationLabels[labelName]; + let gmailLabel = GmailApp.getUserLabelByName(name); + + if (!gmailLabel) { + return false; + } + } + + return true; +} + +/** + * Fetches the ID of a Gmail label by its name. + * @param {string} name The name of the label. + * @returns {string} The ID of the label. + */ +function fetchLabelId(name) { + return Gmail.Users.Labels.list('me').labels.find(_ => _.name === name).id; +} + +/** + * Removes all classification labels from Gmail. + * After removing labels, it logs a message to the console and returns the homepage card. + * @returns {!CardService.Card} The homepage card. + */ +function removeLabels() { + for (const labelName in classificationLabels) { + const classificationLabel = classificationLabels[labelName]; + let gmailLabel = GmailApp.getUserLabelByName(classificationLabel.name); + + if (gmailLabel) { + gmailLabel.deleteLabel(); + delete classificationLabel.gmailLabel; + } + } + console.log('Labels removed.'); + return buildHomepageCard(); +} diff --git a/ai/email-classifier/README.md b/ai/email-classifier/README.md new file mode 100644 index 000000000..e3f7bee9a --- /dev/null +++ b/ai/email-classifier/README.md @@ -0,0 +1,144 @@ +# Email Classifier + +This Apps Script project provides a Gmail add-on that classifies emails based on +their content and subject, and performs actions such as adding labels, creating +draft responses, and logging results in a Google Sheet. It leverages the Gemini +API for natural language processing. + +## Features + +* **Email Classification:** Classifies unread emails in your inbox into three + categories: + * `needs-response`: Emails requiring a direct reply. + * `action-required`: Emails requiring a specific task or decision. + * `for-your-info`: Emails for information only, no action needed. +* **Labeling:** Adds Gmail labels to emails based on their classification. +* **Draft Responses:** Generates draft email responses for emails classified + as `needs-response`. +* **Spreadsheet Logging:** Logs email details, classification, and reason to a + Google Sheet. +* **User-Friendly Interface:** Provides a Gmail add-on with buttons for + classification, label creation, and removal. + +## Setup + +### 1. Enable Google APIs + +* Go to the [Google Cloud Console](https://console.cloud.google.com/). +* Create or select a project. +* **Gemini API:** + * [Enable the Gemini API](https://console.cloud.google.com/flows/enableapi?apiid=aiplatform.googleapis.com) +* **Gmail API:** + * [Enable the Gmail API](https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com) +* **Sheets API:** + * [Enable the Sheets API](https://console.cloud.google.com/flows/enableapi?apiid=sheets.googleapis.com) + +### 2. Apps Script Project + +1. **Create a New Project:** + * Go to [script.google.com](https://script.google.com). + * Create a new project. +1. **Enable `appsscript.json` Manifest:** + * Go to **Project Settings**. + * Check the **Show "appsscript.json" manifest file in editor** option. +1. **Associate with Google Cloud Project:** + * In your Apps Script project, go to **Project Settings**. + * Under **Google Cloud Platform (GCP) Project**, click **Change project**. + * Enter your Google Cloud Project number and click **Set project**. +1. **Copy Code:** + * Copy the code from each `.gs` file in this directory into the + corresponding file in your Apps Script project. +1. **Update `Constants.gs`:** + * Replace the placeholder values in `Constants.gs`: + * `PROJECT_ID`: Your Google Cloud Project ID. + * `ME`: Your name. +1. **Update `appsscript.json`:** + * Ensure the `appsscript.json` file is configured correctly. + +### 3. Configure OAuth Consent Screen + +Google Workspace add-ons require a consent screen configuration. Configuring +your add-on's OAuth consent screen defines what Google displays to users. + +1. **Go to Google Cloud Console:** + * Navigate to the [Google Auth Platform - Branding page](https://console.cloud.google.com/auth/branding). +1. **App Information:** + * **App name:** Enter a name for your add-on (e.g., "Email Classifier"). + * **User support email:** Select your email address. + * **Developer contact information:** Enter your email address. + * Click **Next**. +1. **Audience:** + * Select **Internal**. + * Click **Next**. +1. **Contact Information:** + * Select your email address. + * Click **Next**. +1. **Finish:** + * Check **I agree to the [Google API Services: User Data Policy](https://developers.google.com/terms/api-services-user-data-policy)**. + * Click **Continue**. +1. **Create:** + * Click **Create**. + +### 4. Deploy the Add-on + +1. **Deploy:** + * Click "Deploy" > "Test deployments". + * Select "Gmail add-on". + * Click "Install" to install the add-on for your account. + +## How to Run + +1. **Open Gmail:** + * Open Gmail in your browser. +1. **Open the Add-on:** + * The "Email Classifier" add-on should appear in the right sidebar. +1. **Classify Emails:** + * Click the "Classify emails" button. + * The add-on will process unread emails from the last 7 days. +1. **View Results:** + * A link to the generated Google Sheet will be displayed. + * Open the sheet to view the classification results. +1. **Create/Remove Labels:** + * Use the "Create labels" or "Remove labels" buttons to manage Gmail labels. + +## Code Overview + +* **`Cards.gs`:** + * Defines the UI for the Gmail add-on, including buttons and actions. +* **`ClassifyEmail.gs`:** + * Constructs prompts for the Gemini API. + * Sends email content to the Gemini API for classification. + * Parses the API response. +* **`Code.gs`:** + * Main function to search, classify, label, and log emails. +* **`Constants.gs`:** + * Stores project-specific constants (e.g., API URL, project ID, email). +* **`DraftEmail.gs`:** + * Constructs prompts for the Gemini API to generate draft responses. + * Sends email content to the Gemini API for draft generation. + * Parses the API response. +* **`Labels.gs`:** + * Creates, updates, and removes Gmail labels. +* **`Sheet.gs`:** + * Creates and updates Google Sheets for logging. +* **`appsscript.json`:** + * Configuration file for the Apps Script project. + +## Important Notes + +* **Gemini API Usage:** This project relies on the Gemini API for natural + language processing. Make sure you have the API enabled and have sufficient + quota. +* **OAuth Scopes:** The `appsscript.json` file includes the necessary OAuth + scopes for Gmail, Sheets, and the Gemini API. +* **Error Handling:** The code includes basic error handling, but you may need + to add more robust error handling for production use. +* **Rate Limits:** Be mindful of API rate limits, especially when processing + large numbers of emails. +* **Security:** Ensure that you are handling user data securely. + +## Disclaimer + +This code is provided as-is, without any warranty. Use at your own risk. + +Feel free to modify and adapt this code to your specific needs. diff --git a/ai/email-classifier/Sheet.gs b/ai/email-classifier/Sheet.gs new file mode 100644 index 000000000..b665d757a --- /dev/null +++ b/ai/email-classifier/Sheet.gs @@ -0,0 +1,84 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Creates a spreadsheet with the given headers. + * @param {!Array} headers The headers for the spreadsheet. + * @return {!Spreadsheet} The created spreadsheet. + */ +function createSheetWithHeaders(headers) { + const today = new Date().toLocaleString(); + const spreadsheet = SpreadsheetApp.create(`Emails from ${today}`); + const sheet = spreadsheet.getActiveSheet(); + sheet.getRange(1, 1, 1, headers.length).setValues([headers]); + addTable(spreadsheet); + console.log(`Successfully created spreadsheet: ${spreadsheet.getUrl()}`); + return spreadsheet; +} + +/** + * Adds data to the spreadsheet. + * @param {!Spreadsheet} spreadsheet The spreadsheet to add data to. + * @param {string} subject The subject of the email. + * @param {string} classification The classification of the email. + * @param {string} reason The reason for the classification. + */ +function addDataToSheet(spreadsheet, subject, classification, reason) { + const sheet = spreadsheet.getActiveSheet(); + const newRow = [subject, classification, reason]; + sheet.appendRow(newRow); +} + +/** + * Creates a hyperlink for the given thread. + * @param {!GmailThread} thread The thread to create a hyperlink for. + * @return {string} The hyperlink. + */ +function hyperlink(thread) { + const link = `https://mail.google.com/mail/u/0/#inbox/${thread.getId()}`; + return `=HYPERLINK("${link}", "${thread.getFirstMessageSubject()}")`; +} + +/** + * Adds a table to the spreadsheet with a dropdown for classification. + * @param {!Spreadsheet} ss The spreadsheet to add the table to. + */ +function addTable(ss) { + const values = Object.keys(classificationLabels).map(label => { + return { userEnteredValue: label }; + }); + const addTableRequest = { + requests: [{ + addTable: { + table: { + name: 'Email classification', + range: { + sheetId: 0, + startColumnIndex: 0, + endColumnIndex: 2, + }, + columnProperties: [{ + columnIndex: 1, + columnType: 'DROPDOWN', + dataValidationRule: { condition: { type: 'ONE_OF_LIST', values: values } } + }], + } + } + }] + }; + + Sheets.Spreadsheets.batchUpdate(addTableRequest, ss.getId()); +} diff --git a/ai/email-classifier/appsscript.json b/ai/email-classifier/appsscript.json new file mode 100644 index 000000000..baa8ca4ce --- /dev/null +++ b/ai/email-classifier/appsscript.json @@ -0,0 +1,38 @@ +{ + "timeZone": "America/Los_Angeles", + "dependencies": { + "enabledAdvancedServices": [ + { + "userSymbol": "Gmail", + "version": "v1", + "serviceId": "gmail" + }, + { + "userSymbol": "Sheets", + "version": "v4", + "serviceId": "sheets" + } + ] + }, + "oauthScopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/gmail.addons.execute", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/script.external_request", + "https://www.googleapis.com/auth/spreadsheets" + ], + "addOns": { + "common": { + "name": "Email Classifier", + "logoUrl": "https://fonts.gstatic.com/s/i/googlematerialicons/label_important/v20/googblue-24dp/1x/gm_label_important_googblue_24dp.png" + }, + "gmail": { + "homepageTrigger": { + "runFunction": "onHomepageTrigger", + "enabled": true + } + } + }, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8" +} diff --git a/ai/gmail-sentiment-analysis/Cards.gs b/ai/gmail-sentiment-analysis/Cards.gs new file mode 100644 index 000000000..566c1ca47 --- /dev/null +++ b/ai/gmail-sentiment-analysis/Cards.gs @@ -0,0 +1,67 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + + +/** + * Builds the card for to display in the sidepanel of gmail. + * @return {CardService.Card} The card to show to the user. + */ + +function buildCard_GmailHome(notifyOk=false){ + const imageUrl ='https://icons.iconarchive.com/icons/roundicons/100-free-solid/48/spy-icon.png'; + const image = CardService.newImage() + .setImageUrl(imageUrl); + + const cardHeader = CardService.newCardHeader() + .setImageUrl(imageUrl) + .setImageStyle(CardService.ImageStyle.CIRCLE) + .setTitle("Analyze your GMail"); + + const action = CardService.newAction() + .setFunctionName('analyzeSentiment'); + const button = CardService.newTextButton() + .setText('Identify angry customers') + .setOnClickAction(action) + .setTextButtonStyle(CardService.TextButtonStyle.FILLED); + const buttonSet = CardService.newButtonSet() + .addButton(button); + + const section = CardService.newCardSection() + .setHeader("Emails sentiment analysis") + .addWidget(buttonSet); + + const card = CardService.newCardBuilder() + .setHeader(cardHeader) + .addSection(section); + +/** + * This builds the card that contains the footer that informs + * the user about the successful execution of the Add-on. + */ + +if(notifyOk==true){ + let fixedFooter = CardService.newFixedFooter() + .setPrimaryButton( + CardService.newTextButton() + .setText("Analysis complete") + .setOnClickAction( + CardService.newAction() + .setFunctionName( + "buildCard_GmailHome"))); + card.setFixedFooter(fixedFooter); +} + return card.build(); +} \ No newline at end of file diff --git a/ai/gmail-sentiment-analysis/Code.gs b/ai/gmail-sentiment-analysis/Code.gs new file mode 100644 index 000000000..7b353e735 --- /dev/null +++ b/ai/gmail-sentiment-analysis/Code.gs @@ -0,0 +1,24 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Callback for rendering the homepage card. + * @return {CardService.Card} The card to show to the user. + */ +function onHomepage(e) { + return buildCard_GmailHome(); +} + diff --git a/ai/gmail-sentiment-analysis/Gmail.gs b/ai/gmail-sentiment-analysis/Gmail.gs new file mode 100644 index 000000000..1bf87b1a9 --- /dev/null +++ b/ai/gmail-sentiment-analysis/Gmail.gs @@ -0,0 +1,49 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Callback for initiating the sentiment analysis. + * @return {CardService.Card} The card to show to the user. + */ + +function analyzeSentiment(){ + emailSentiment(); + return buildCard_GmailHome(true); +} + +/** + * Gets the last 10 threads in the inbox and the corresponding messages. + * Fetches the label that should be applied to negative messages. + * The processSentiment is called on each message + * and tested with RegExp to check for a negative answer from the model + */ + +function emailSentiment() { + const threads = GmailApp.getInboxThreads(0, 10); + const msgs = GmailApp.getMessagesForThreads(threads); + const label_upset = GmailApp.getUserLabelByName("UPSET TONE 😡"); + let currentPrediction; + + for (let i = 0 ; i < msgs.length; i++) { + for (let j = 0; j < msgs[i].length; j++) { + let emailText = msgs[i][j].getPlainBody(); + currentPrediction = processSentiment(emailText); + if(currentPrediction === true){ + label_upset.addToThread(msgs[i][j].getThread()); + } + } + } +} \ No newline at end of file diff --git a/ai/gmail-sentiment-analysis/README.md b/ai/gmail-sentiment-analysis/README.md new file mode 100644 index 000000000..1638b0977 --- /dev/null +++ b/ai/gmail-sentiment-analysis/README.md @@ -0,0 +1,31 @@ +# Gmail sentiment analysis with Vertex AI + +## Project Description + +Google Workspace Add-on that extends Gmail and adds sentiment analysis capabilities. + +## Prerequisites + +* Google Cloud Project (aka Standard Cloud Project for Apps Script) with billing enabled + +## Set up your environment + +1. Create a Cloud Project + 1. Enable the Vertex AI API + 1. Create a Service Account and grant the role `Vertex AI User` + 1. Create a private key with type JSON. This will download the JSON file for use in the next section. +1. Open an Apps Script Project bound to a Google Sheets Spreadsheet + 1. From Project Settings, change project to GCP project number of Cloud Project from step 1 + 1. Add a Script Property. Enter `service_account_key` as the property name and paste the JSON key from the service account as the value. +1. Add OAuth2 v43 Apps Script Library using the ID `1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF`. +1. Add the project code to Apps Script + +## Usage + +1. Create a label in Gmail with this exact text and emojy (case sensitive!): UPSET TONE 😡 +1. In Gmail, click on the Productivity toolbox icon (icon of a spy) in the sidepanel. +1. The sidepanel will open up. Grant the Add-on autorization to run. +1. The Add-on will load. Click on the blue button "Identify angry customers." +1. Close the Add-on by clicking on the X in the top right corner. +1. It can take a couple of minutes until the label is applied to the messages that have a negative tone. +1. If you don't want to wait until the labels are added, you can refresh the browser. \ No newline at end of file diff --git a/ai/gmail-sentiment-analysis/Vertex.gs b/ai/gmail-sentiment-analysis/Vertex.gs new file mode 100644 index 000000000..f6299ecde --- /dev/null +++ b/ai/gmail-sentiment-analysis/Vertex.gs @@ -0,0 +1,97 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const PROJECT_ID = [ADD YOUR GCP PROJECT ID HERE]; +const VERTEX_AI_LOCATION = 'europe-west2'; +const MODEL_ID = 'gemini-1.5-pro-002'; +const SERVICE_ACCOUNT_KEY = PropertiesService.getScriptProperties().getProperty('service_account_key'); + +/** + * Packages prompt and necessary settings, then sends a request to + * Vertex API. + * A check is performed to see if the response from Vertex AI contains FALSE as a value. + * Returns the outcome of that check which is a boolean. + * + * @param emailText - Email message that is sent to the model. + */ + +function processSentiment(emailText) { + const prompt = `Analyze the following message: ${emailText}. If the sentiment of this message is negative, answer with FALSE. If the sentiment of this message is neutral or positive, answer with TRUE. Do not use any other words than the ones requested in this prompt as a response!`; + + const request = { + "contents": [{ + "role": "user", + "parts": [{ + "text": prompt + }] + }], + "generationConfig": { + "temperature": 0.9, + "maxOutputTokens": 1024, + + } + }; + + const credentials = credentialsForVertexAI(); + + const fetchOptions = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.accessToken}` + }, + contentType: 'application/json', + muteHttpExceptions: true, + payload: JSON.stringify(request) + } + + const url = `https://${VERTEX_AI_LOCATION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/` + + `locations/${VERTEX_AI_LOCATION}/publishers/google/models/${MODEL_ID}:generateContent` + + const response = UrlFetchApp.fetch(url, fetchOptions); + const payload = JSON.parse(response.getContentText()); + + const regex = /FALSE/; + + return regex.test(payload.candidates[0].content.parts[0].text); + +} + +/** + * Gets credentials required to call Vertex API using a Service Account. + * Requires use of Service Account Key stored with project + * + * @return {!Object} Containing the Cloud Project Id and the access token. + */ + +function credentialsForVertexAI() { + const credentials = SERVICE_ACCOUNT_KEY; + if (!credentials) { + throw new Error("service_account_key script property must be set."); + } + + const parsedCredentials = JSON.parse(credentials); + + const service = OAuth2.createService("Vertex") + .setTokenUrl('https://oauth2.googleapis.com/token') + .setPrivateKey(parsedCredentials['private_key']) + .setIssuer(parsedCredentials['client_email']) + .setPropertyStore(PropertiesService.getScriptProperties()) + .setScope("https://www.googleapis.com/auth/cloud-platform"); + return { + projectId: parsedCredentials['project_id'], + accessToken: service.getAccessToken(), + } +} \ No newline at end of file diff --git a/ai/gmail-sentiment-analysis/appsscript.json b/ai/gmail-sentiment-analysis/appsscript.json new file mode 100644 index 000000000..80b231e9a --- /dev/null +++ b/ai/gmail-sentiment-analysis/appsscript.json @@ -0,0 +1,27 @@ +{ + "timeZone": "Europe/Madrid", + "dependencies": { + "libraries": [ + { + "userSymbol": "OAuth2", + "version": "43", + "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF" + } + ] + }, + "addOns": { + "common": { + "name": "Productivity toolbox", + "logoUrl": "https://icons.iconarchive.com/icons/roundicons/100-free-solid/64/spy-icon.png", + "useLocaleFromApp": true + }, + "gmail": { + "homepageTrigger": { + "runFunction": "onHomepage", + "enabled": true + } + } + }, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8" +} \ No newline at end of file diff --git a/chat/advanced-service/AppAuthenticationUtils.gs b/chat/advanced-service/AppAuthenticationUtils.gs new file mode 100644 index 000000000..87af3ba93 --- /dev/null +++ b/chat/advanced-service/AppAuthenticationUtils.gs @@ -0,0 +1,61 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// [START chat_authentication_utils] + +// This script provides configuration and helper functions for app authentication. +// It may require modifications to work in your environment. + +// For more information on app authentication, see +// https://developers.google.com/workspace/chat/authenticate-authorize-chat-app + +const APP_AUTH_OAUTH_SCOPES = ['https://www.googleapis.com/auth/chat.bot']; +// Warning: This example uses a service account private key, it should always be stored in a +// secure location. +const SERVICE_ACCOUNT = { + // TODO(developer): Replace with the Google Chat credentials to use for app authentication, + // the service account private key's JSON. +}; + +/** + * Authenticates the app service by using the OAuth2 library. + * + * @return {Object} the authenticated app service + */ +function getService_() { + return OAuth2.createService(SERVICE_ACCOUNT.client_email) + .setTokenUrl(SERVICE_ACCOUNT.token_uri) + .setPrivateKey(SERVICE_ACCOUNT.private_key) + .setIssuer(SERVICE_ACCOUNT.client_email) + .setSubject(SERVICE_ACCOUNT.client_email) + .setScope(APP_AUTH_OAUTH_SCOPES) + .setCache(CacheService.getUserCache()) + .setLock(LockService.getUserLock()) + .setPropertyStore(PropertiesService.getScriptProperties()); +} + +/** + * Generates headers with the app credentials to use to make Google Chat API calls. + * + * @return {Object} the header with credentials + */ +function getHeaderWithAppCredentials() { + return { + 'Authorization': `Bearer ${getService_().getAccessToken()}` + }; +} + +// [END chat_authentication_utils] diff --git a/chat/advanced-service/Main.gs b/chat/advanced-service/Main.gs new file mode 100644 index 000000000..b029ef102 --- /dev/null +++ b/chat/advanced-service/Main.gs @@ -0,0 +1,818 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This script provides each code sample in a separate function. +// It may require modifications to work in your environment. + +// For more information on user authentication, see +// https://developers.google.com/workspace/chat/authenticate-authorize-chat-user + +// For more information on app authentication, see +// https://developers.google.com/workspace/chat/authenticate-authorize-chat-app + +// [START chat_create_membership_user_cred] +/** + * This sample shows how to create membership with user credential for a human user + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.memberships' + * referenced in the manifest file (appsscript.json). + */ +function createMembershipUserCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here. + const parent = 'spaces/SPACE_NAME'; + const membership = { + member: { + // TODO(developer): Replace USER_NAME here + name: 'users/USER_NAME', + // User type for the membership + type: 'HUMAN' + } + }; + + // Make the request + const response = Chat.Spaces.Members.create(membership, parent); + + // Handle the response + console.log(response); +} +// [END chat_create_membership_user_cred] + +// [START chat_create_membership_user_cred_for_app] +/** + * This sample shows how to create membership with app credential for an app + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.memberships.app' + * referenced in the manifest file (appsscript.json). + */ +function createMembershipUserCredForApp() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here. + const parent = 'spaces/SPACE_NAME'; + const membership = { + member: { + // Member name for app membership, do not change this + name: 'users/app', + // User type for the membership + type: 'BOT' + } + }; + + // Make the request + const response = Chat.Spaces.Members.create(membership, parent); + + // Handle the response + console.log(response); +} +// [END chat_create_membership_user_cred_for_app] + +// [START chat_create_membership_user_cred_for_group] +/** + * This sample shows how to create membership with user credential for a group + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.memberships' + * referenced in the manifest file (appsscript.json). + */ +function createMembershipUserCredForGroup() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here. + const parent = 'spaces/SPACE_NAME'; + const membership = { + groupMember: { + // TODO(developer): Replace GROUP_NAME here + name: 'groups/GROUP_NAME' + } + }; + + // Make the request + const response = Chat.Spaces.Members.create(membership, parent); + + // Handle the response + console.log(response); +} +// [END chat_create_membership_user_cred_for_group] + + +// [START chat_create_message_app_cred] +/** + * This sample shows how to create message with app credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' + * used by service accounts. + */ +function createMessageAppCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here. + const parent = 'spaces/SPACE_NAME'; + const message = { + text: '👋🌎 Hello world! I created this message by calling ' + + 'the Chat API\'s `messages.create()` method.', + cardsV2 : [{ card: { + header: { + title: 'About this message', + imageUrl: 'https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/info/default/24px.svg' + }, + sections: [{ + header: 'Contents', + widgets: [{ textParagraph: { + text: '🔡 Text which can include ' + + 'hyperlinks 🔗, emojis 😄🎉, and @mentions đŸ—Ŗī¸.' + }}, { textParagraph: { + text: 'đŸ–ŧī¸ A card to display visual elements' + + 'and request information such as text 🔤, ' + + 'dates and times 📅, and selections â˜‘ī¸.' + }}, { textParagraph: { + text: '👉🔘 An accessory widget which adds ' + + 'a button to the bottom of a message.' + }} + ]}, { + header: "What's next", + collapsible: true, + widgets: [{ textParagraph: { + text: "â¤ī¸ Add a reaction." + }}, { textParagraph: { + text: "🔄 Update " + + "or ❌ delete " + + "the message." + } + }] + } + ] + }}], + accessoryWidgets: [{ buttonList: { buttons: [{ + text: 'View documentation', + icon: { materialIcon: { name: 'link' }}, + onClick: { openLink: { + url: 'https://developers.google.com/workspace/chat/create-messages' + }} + }]}}] + }; + const parameters = {}; + + // Make the request + const response = Chat.Spaces.Messages.create( + message, parent, parameters, getHeaderWithAppCredentials() + ); + + // Handle the response + console.log(response); +} +// [END chat_create_message_app_cred] + +// [START chat_create_message_user_cred] +/** + * This sample shows how to create message with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' + * referenced in the manifest file (appsscript.json). + */ +function createMessageUserCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here. + const parent = 'spaces/SPACE_NAME'; + const message = { + text: '👋🌎 Hello world!' + + 'Text messages can contain things like:\n\n' + + '* Hyperlinks 🔗\n' + + '* Emojis 😄🎉\n' + + '* Mentions of other Chat users `@` \n\n' + + 'For details, see the ' + + '.' + }; + + // Make the request + const response = Chat.Spaces.Messages.create(message, parent); + + // Handle the response + console.log(response); +} +// [END chat_create_message_user_cred] + +// [START chat_create_message_user_cred_at_mention] +/** + * This sample shows how to create message with user credential with a user mention + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' + * referenced in the manifest file (appsscript.json). + */ +function createMessageUserCredAtMention() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here. + const parent = 'spaces/SPACE_NAME'; + const message = { + // The user with USER_NAME will be mentioned if they are in the space + // TODO(developer): Replace USER_NAME here + text: 'Hello !' + }; + + // Make the request + const response = Chat.Spaces.Messages.create(message, parent); + + // Handle the response + console.log(response); +} +// [END chat_create_message_user_cred_at_mention] + +// [START chat_create_message_user_cred_message_id] +/** + * This sample shows how to create message with user credential with message id + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' + * referenced in the manifest file (appsscript.json). + */ +function createMessageUserCredMessageId() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here. + const parent = 'spaces/SPACE_NAME'; + // Message id lets chat apps get, update or delete a message without needing + // to store the system assigned ID in the message's resource name + const messageId = 'client-MESSAGE-ID'; + const message = { text: 'Hello with user credential!' }; + + // Make the request + const response = Chat.Spaces.Messages.create(message, parent, { + messageId: messageId + }); + + // Handle the response + console.log(response); +} +// [END chat_create_message_user_cred_message_id] + +// [START chat_create_message_user_cred_request_id] +/** + * This sample shows how to create message with user credential with request id + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' + * referenced in the manifest file (appsscript.json). + */ +function createMessageUserCredRequestId() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here. + const parent = 'spaces/SPACE_NAME'; + // Specifying an existing request ID returns the message created with + // that ID instead of creating a new message + const requestId = 'REQUEST_ID'; + const message = { text: 'Hello with user credential!' }; + + // Make the request + const response = Chat.Spaces.Messages.create(message, parent, { + requestId: requestId + }); + + // Handle the response + console.log(response); +} +// [END chat_create_message_user_cred_request_id] + +// [START chat_create_message_user_cred_thread_key] +/** + * This sample shows how to create message with user credential with thread key + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' + * referenced in the manifest file (appsscript.json). + */ +function createMessageUserCredThreadKey() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here. + const parent = 'spaces/SPACE_NAME'; + // Creates the message as a reply to the thread specified by thread_key + // If it fails, the message starts a new thread instead + const messageReplyOption = 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD'; + const message = { + text: 'Hello with user credential!', + thread: { + // Thread key specifies a thread and is unique to the chat app + // that sets it + threadKey: 'THREAD_KEY' + } + }; + + // Make the request + const response = Chat.Spaces.Messages.create(message, parent, { + messageReplyOption: messageReplyOption + }); + + // Handle the response + console.log(response); +} +// [END chat_create_message_user_cred_thread_key] + +// [START chat_create_message_user_cred_thread_name] +/** + * This sample shows how to create message with user credential with thread name + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' + * referenced in the manifest file (appsscript.json). + */ +function createMessageUserCredThreadName() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here. + const parent = 'spaces/SPACE_NAME'; + // Creates the message as a reply to the thread specified by thread.name + // If it fails, the message starts a new thread instead + const messageReplyOption = 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD'; + const message = { + text: 'Hello with user credential!', + thread: { + // Resource name of a thread that uniquely identify a thread + // TODO(developer): Replace SPACE_NAME and THREAD_NAME here + name: 'spaces/SPACE_NAME/threads/THREAD_NAME' + } + }; + + // Make the request + const response = Chat.Spaces.Messages.create(message, parent, { + messageReplyOption: messageReplyOption + }); + + // Handle the response + console.log(response); +} +// [END chat_create_message_user_cred_thread_name] + +// [START chat_create_space_user_cred] +/** + * This sample shows how to create space with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces.create' + * referenced in the manifest file (appsscript.json). + */ +function createSpaceUserCred() { + // Initialize request argument(s) + const space = { + spaceType: 'SPACE', + // TODO(developer): Replace DISPLAY_NAME here + displayName: 'DISPLAY_NAME' + }; + + // Make the request + const response = Chat.Spaces.create(space); + + // Handle the response + console.log(response); +} +// [END chat_create_space_user_cred] + +// [START chat_delete_message_app_cred] +/** + * This sample shows how to delete a message with app credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' + * used by service accounts. + */ +function deleteMessageAppCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here + const name = 'spaces/SPACE_NAME/messages/MESSAGE_NAME'; + const parameters = {}; + + // Make the request + const response = Chat.Spaces.Messages.remove(name, parameters, getHeaderWithAppCredentials()); + + // Handle the response + console.log(response); +} +// [END chat_delete_message_app_cred] + +// [START chat_delete_message_user_cred] +/** + * This sample shows how to delete a message with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages' + * referenced in the manifest file (appsscript.json). + */ +function deleteMessageUserCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here + const name = 'spaces/SPACE_NAME/messages/MESSAGE_NAME'; + + // Make the request + const response = Chat.Spaces.Messages.remove(name); + + // Handle the response + console.log(response); +} +// [END chat_delete_message_user_cred] + +// [START chat_get_membership_app_cred] +/** + * This sample shows how to get membership with app credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' + * used by service accounts. + */ +function getMembershipAppCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME and MEMBER_NAME here + const name = 'spaces/SPACE_NAME/members/MEMBER_NAME'; + const parameters = {}; + + // Make the request + const response = Chat.Spaces.Members.get(name, parameters, getHeaderWithAppCredentials()); + + // Handle the response + console.log(response); +} +// [END chat_get_membership_app_cred] + +// [START chat_get_membership_user_cred] +/** + * This sample shows how to get membership with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.memberships.readonly' + * referenced in the manifest file (appsscript.json). + */ +function getMembershipUserCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME and MEMBER_NAME here + const name = 'spaces/SPACE_NAME/members/MEMBER_NAME'; + + // Make the request + const response = Chat.Spaces.Members.get(name); + + // Handle the response + console.log(response); +} +// [END chat_get_membership_user_cred] + +// [START chat_get_message_app_cred] +/** + * This sample shows how to get message with app credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' + * used by service accounts. + */ +function getMessageAppCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here + const name = 'spaces/SPACE_NAME/messages/MESSAGE_NAME'; + const parameters = {}; + + // Make the request + const response = Chat.Spaces.Messages.get(name, parameters, getHeaderWithAppCredentials()); + + // Handle the response + console.log(response); +} +// [END chat_get_message_app_cred] + +// [START chat_get_message_user_cred] +/** + * This sample shows how to get message with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.readonly' + * referenced in the manifest file (appsscript.json). + */ +function getMessageUserCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here + const name = 'spaces/SPACE_NAME/messages/MESSAGE_NAME'; + + // Make the request + const response = Chat.Spaces.Messages.get(name); + + // Handle the response + console.log(response); +} +// [END chat_get_message_user_cred] + +// [START chat_get_space_app_cred] +/** + * This sample shows how to get space with app credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' + * used by service accounts. + */ +function getSpaceAppCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here + const name = 'spaces/SPACE_NAME'; + const parameters = {}; + + // Make the request + const response = Chat.Spaces.get(name, parameters, getHeaderWithAppCredentials()); + + // Handle the response + console.log(response); +} +// [END chat_get_space_app_cred] + +// [START chat_get_space_user_cred] +/** + * This sample shows how to get space with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces.readonly' + * referenced in the manifest file (appsscript.json). + */ +function getSpaceUserCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here + const name = 'spaces/SPACE_NAME'; + + // Make the request + const response = Chat.Spaces.get(name); + + // Handle the response + console.log(response); +} +// [END chat_get_space_user_cred] + +// [START chat_list_memberships_app_cred] +/** + * This sample shows how to list memberships with app credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' + * used by service accounts. + */ +function listMembershipsAppCred() { +// Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here + const parent = 'spaces/SPACE_NAME'; + // Filter membership by type (HUMAN or BOT) or role (ROLE_MEMBER or + // ROLE_MANAGER) + const filter = 'member.type = "HUMAN"'; + + // Iterate through the response pages using page tokens + let responsePage; + let pageToken = null; + do { + // Request response pages + responsePage = Chat.Spaces.Members.list(parent, { + filter: filter, + pageSize: 10, + pageToken: pageToken + }, getHeaderWithAppCredentials()); + // Handle response pages + if (responsePage.memberships) { + responsePage.memberships.forEach((membership) => console.log(membership)); + } + // Update the page token to the next one + pageToken = responsePage.nextPageToken; + } while (pageToken); +} +// [END chat_list_memberships_app_cred] + +// [START chat_list_memberships_user_cred] +/** + * This sample shows how to list memberships with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.memberships.readonly' + * referenced in the manifest file (appsscript.json). + */ +function listMembershipsUserCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here + const parent = 'spaces/SPACE_NAME'; + // Filter membership by type (HUMAN or BOT) or role (ROLE_MEMBER or + // ROLE_MANAGER) + const filter = 'member.type = "HUMAN"'; + + // Iterate through the response pages using page tokens + let responsePage; + let pageToken = null; + do { + // Request response pages + responsePage = Chat.Spaces.Members.list(parent, { + filter: filter, + pageSize: 10, + pageToken: pageToken + }); + // Handle response pages + if (responsePage.memberships) { + responsePage.memberships.forEach((membership) => console.log(membership)); + } + // Update the page token to the next one + pageToken = responsePage.nextPageToken; + } while (pageToken); +} +// [END chat_list_memberships_user_cred] + +// [START chat_list_messages_user_cred] +/** + * This sample shows how to list messages with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.readonly' + * referenced in the manifest file (appsscript.json). + */ +function listMessagesUserCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here + const parent = 'spaces/SPACE_NAME'; + + // Iterate through the response pages using page tokens + let responsePage; + let pageToken = null; + do { + // Request response pages + responsePage = Chat.Spaces.Messages.list(parent, { + pageSize: 10, + pageToken: pageToken + }); + // Handle response pages + if (responsePage.messages) { + responsePage.messages.forEach((message) => console.log(message)); + } + // Update the page token to the next one + pageToken = responsePage.nextPageToken; + } while (pageToken); +} +// [END chat_list_messages_user_cred] + +// [START chat_list_spaces_app_cred] +/** + * This sample shows how to list spaces with app credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' + * used by service accounts. + */ +function listSpacesAppCred() { + // Initialize request argument(s) + // Filter spaces by space type (SPACE or GROUP_CHAT or DIRECT_MESSAGE) + const filter = 'space_type = "SPACE"'; + + // Iterate through the response pages using page tokens + let responsePage; + let pageToken = null; + do { + // Request response pages + responsePage = Chat.Spaces.list({ + filter: filter, + pageSize: 10, + pageToken: pageToken + }, getHeaderWithAppCredentials()); + // Handle response pages + if (responsePage.spaces) { + responsePage.spaces.forEach((space) => console.log(space)); + } + // Update the page token to the next one + pageToken = responsePage.nextPageToken; + } while (pageToken); +} +// [END chat_list_spaces_app_cred] + +// [START chat_list_spaces_user_cred] +/** + * This sample shows how to list spaces with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces.readonly' + * referenced in the manifest file (appsscript.json). + */ +function listSpacesUserCred() { + // Initialize request argument(s) + // Filter spaces by space type (SPACE or GROUP_CHAT or DIRECT_MESSAGE) + const filter = 'space_type = "SPACE"'; + + // Iterate through the response pages using page tokens + let responsePage; + let pageToken = null; + do { + // Request response pages + responsePage = Chat.Spaces.list({ + filter: filter, + pageSize: 10, + pageToken: pageToken + }); + // Handle response pages + if (responsePage.spaces) { + responsePage.spaces.forEach((space) => console.log(space)); + } + // Update the page token to the next one + pageToken = responsePage.nextPageToken; + } while (pageToken); +} +// [END chat_list_spaces_user_cred] + +// [START chat_set_up_space_user_cred] +/** + * This sample shows how to set up a named space with one initial member with + * user credential. + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces.create' + * referenced in the manifest file (appsscript.json). + */ +function setUpSpaceUserCred() { + // Initialize request argument(s) + const space = { + spaceType: 'SPACE', + // TODO(developer): Replace DISPLAY_NAME here + displayName: 'DISPLAY_NAME' + }; + const memberships = [{ + member: { + // TODO(developer): Replace USER_NAME here + name: 'users/USER_NAME', + // User type for the membership + type: 'HUMAN' + } + }]; + + // Make the request + const response = Chat.Spaces.setup({ space: space, memberships: memberships }); + + // Handle the response + console.log(response); +} +// [END chat_set_up_space_user_cred] + +// [START chat_update_message_app_cred] +/** + * This sample shows how to update a message with app credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' + * used by service accounts. + */ +function updateMessageAppCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here + const name = 'spaces/SPACE_NAME/messages/MESSAGE_NAME'; + const message = { + text: 'Text updated with app credential!', + cardsV2 : [{ card: { header: { + title: 'Card updated with app credential!', + imageUrl: 'https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/info/default/24px.svg' + }}}] + }; + // The field paths to update. Separate multiple values with commas or use + // `*` to update all field paths. + const updateMask = 'text,cardsV2'; + + // Make the request + const response = Chat.Spaces.Messages.patch(message, name, { + updateMask: updateMask + }, getHeaderWithAppCredentials()); + + // Handle the response + console.log(response); +} +// [END chat_update_message_app_cred] + +// [START chat_update_message_user_cred] +/** + * This sample shows how to update a message with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages' + * referenced in the manifest file (appsscript.json). + */ +function updateMessageUserCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here + const name = 'spaces/SPACE_NAME/messages/MESSAGE_NAME'; + const message = { + text: 'Updated with user credential!' + }; + // The field paths to update. Separate multiple values with commas or use + // `*` to update all field paths. + const updateMask = 'text'; + + // Make the request + const response = Chat.Spaces.Messages.patch(message, name, { + updateMask: updateMask + }); + + // Handle the response + console.log(response); +} +// [END chat_update_message_user_cred] + +// [START chat_update_space_user_cred] +/** + * This sample shows how to update a space with user credential + * + * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces' + * referenced in the manifest file (appsscript.json). + */ +function updateSpaceUserCred() { + // Initialize request argument(s) + // TODO(developer): Replace SPACE_NAME here + const name = 'spaces/SPACE_NAME'; + const space = { + displayName: 'New space display name' + }; + // The field paths to update. Separate multiple values with commas or use + // `*` to update all field paths. + const updateMask = 'displayName'; + + // Make the request + const response = Chat.Spaces.patch(space, name, { + updateMask: updateMask + }); + + // Handle the response + console.log(response); +} +// [END chat_update_space_user_cred] diff --git a/chat/advanced-service/README.md b/chat/advanced-service/README.md new file mode 100644 index 000000000..1c6ad18e7 --- /dev/null +++ b/chat/advanced-service/README.md @@ -0,0 +1,24 @@ +# Google Chat API - Advanced Service samples + +## Set up + +1. Follow the Google Chat app quickstart for Apps Script + https://developers.google.com/workspace/chat/quickstart/apps-script-app and + open the resulting Apps Script project in a web browser. + +1. Override the Apps Script project contents with the files `appsscript.json`, + `AppAuthenticationUtils.gs`, and `Main.gs` from this code sample directory. + +1. To run samples that use app credentials: + + 1. Create a service account. For steps, see + [Authenticate as a Google Chat app](https://developers.google.com/workspace/chat/authenticate-authorize-chat-app). + + 1. Open `AppAuthenticationUtils.gs` and set the value of the constant `SERVICE_ACCOUNT` to + the private key's JSON of the service account that you created in the previous step. + +## Run + +In the `Main.gs` file, each function contains a sample that calls a Chat API method +using either app or user authentication. To run one of the samples, select the name +of the function from the dropdown menu and click `Run`. diff --git a/chat/advanced-service/appsscript.json b/chat/advanced-service/appsscript.json new file mode 100644 index 000000000..7f1d4d24f --- /dev/null +++ b/chat/advanced-service/appsscript.json @@ -0,0 +1,29 @@ +{ + "timeZone": "America/New_York", + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8", + "oauthScopes": [ + "https://www.googleapis.com/auth/chat.spaces", + "https://www.googleapis.com/auth/chat.spaces.create", + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.memberships", + "https://www.googleapis.com/auth/chat.memberships.app", + "https://www.googleapis.com/auth/chat.memberships.readonly", + "https://www.googleapis.com/auth/chat.messages", + "https://www.googleapis.com/auth/chat.messages.create", + "https://www.googleapis.com/auth/chat.messages.readonly" + ], + "chat": {}, + "dependencies": { + "enabledAdvancedServices": [{ + "userSymbol": "Chat", + "version": "v1", + "serviceId": "chat" + }], + "libraries": [{ + "userSymbol": "OAuth2", + "version": "43", + "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF" + }] + } +} diff --git a/data-studio/auth.gs b/data-studio/auth.gs index 5035107ee..c595c188a 100644 --- a/data-studio/auth.gs +++ b/data-studio/auth.gs @@ -20,7 +20,7 @@ * @return {object} The Auth type. */ function getAuthType() { - var cc = DataStudioApp.createCommunityConnector(); + const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse() .setAuthType(cc.AuthType.OAUTH2) .build(); @@ -33,7 +33,7 @@ function getAuthType() { * @return {object} The Auth type. */ function getAuthType() { - var cc = DataStudioApp.createCommunityConnector(); + const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse() .setAuthType(cc.AuthType.PATH_USER_PASS) .setHelpUrl('https://www.example.org/connector-auth-help') @@ -47,7 +47,7 @@ function getAuthType() { * @return {object} The Auth type. */ function getAuthType() { - var cc = DataStudioApp.createCommunityConnector(); + const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse() .setAuthType(cc.AuthType.USER_PASS) .setHelpUrl('https://www.example.org/connector-auth-help') @@ -61,7 +61,7 @@ function getAuthType() { * @return {object} The Auth type. */ function getAuthType() { - var cc = DataStudioApp.createCommunityConnector(); + const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse() .setAuthType(cc.AuthType.USER_TOKEN) .setHelpUrl('https://www.example.org/connector-auth-help') @@ -75,7 +75,7 @@ function getAuthType() { * @return {object} The Auth type. */ function getAuthType() { - var cc = DataStudioApp.createCommunityConnector(); + const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse() .setAuthType(cc.AuthType.KEY) .setHelpUrl('https://www.example.org/connector-auth-help') @@ -89,7 +89,7 @@ function getAuthType() { * @return {object} The Auth type. */ function getAuthType() { - var cc = DataStudioApp.createCommunityConnector(); + const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse() .setAuthType(cc.AuthType.NONE) .build(); @@ -110,7 +110,7 @@ function resetAuth() { * Resets the auth service. */ function resetAuth() { - var userProperties = PropertiesService.getUserProperties(); + const userProperties = PropertiesService.getUserProperties(); userProperties.deleteProperty('dscc.path'); userProperties.deleteProperty('dscc.username'); userProperties.deleteProperty('dscc.password'); @@ -122,7 +122,7 @@ function resetAuth() { * Resets the auth service. */ function resetAuth() { - var userProperties = PropertiesService.getUserProperties(); + const userProperties = PropertiesService.getUserProperties(); userProperties.deleteProperty('dscc.username'); userProperties.deleteProperty('dscc.password'); } @@ -133,7 +133,7 @@ function resetAuth() { * Resets the auth service. */ function resetAuth() { - var userTokenProperties = PropertiesService.getUserProperties(); + const userTokenProperties = PropertiesService.getUserProperties(); userTokenProperties.deleteProperty('dscc.username'); userTokenProperties.deleteProperty('dscc.password'); } @@ -144,7 +144,7 @@ function resetAuth() { * Resets the auth service. */ function resetAuth() { - var userProperties = PropertiesService.getUserProperties(); + const userProperties = PropertiesService.getUserProperties(); userProperties.deleteProperty('dscc.key'); } // [END apps_script_data_studio_auth_reset_key] @@ -165,10 +165,10 @@ function isAuthValid() { * @return {boolean} True if the auth service has access. */ function isAuthValid() { - var userProperties = PropertiesService.getUserProperties(); - var path = userProperties.getProperty('dscc.path'); - var userName = userProperties.getProperty('dscc.username'); - var password = userProperties.getProperty('dscc.password'); + const userProperties = PropertiesService.getUserProperties(); + const path = userProperties.getProperty('dscc.path'); + const userName = userProperties.getProperty('dscc.username'); + const password = userProperties.getProperty('dscc.password'); // This assumes you have a validateCredentials function that // can validate if the userName and password are correct. return validateCredentials(path, userName, password); @@ -181,9 +181,9 @@ function isAuthValid() { * @return {boolean} True if the auth service has access. */ function isAuthValid() { - var userProperties = PropertiesService.getUserProperties(); - var userName = userProperties.getProperty('dscc.username'); - var password = userProperties.getProperty('dscc.password'); + const userProperties = PropertiesService.getUserProperties(); + const userName = userProperties.getProperty('dscc.username'); + const password = userProperties.getProperty('dscc.password'); // This assumes you have a validateCredentials function that // can validate if the userName and password are correct. return validateCredentials(userName, password); @@ -196,9 +196,9 @@ function isAuthValid() { * @return {boolean} True if the auth service has access. */ function isAuthValid() { - var userProperties = PropertiesService.getUserProperties(); - var userName = userProperties.getProperty('dscc.username'); - var token = userProperties.getProperty('dscc.token'); + const userProperties = PropertiesService.getUserProperties(); + const userName = userProperties.getProperty('dscc.username'); + const token = userProperties.getProperty('dscc.token'); // This assumes you have a validateCredentials function that // can validate if the userName and token are correct. return validateCredentials(userName, token); @@ -211,8 +211,8 @@ function isAuthValid() { * @return {boolean} True if the auth service has access. */ function isAuthValid() { - var userProperties = PropertiesService.getUserProperties(); - var key = userProperties.getProperty('dscc.key'); + const userProperties = PropertiesService.getUserProperties(); + const key = userProperties.getProperty('dscc.key'); // This assumes you have a validateKey function that can validate // if the key is valid. return validateKey(key); @@ -243,7 +243,7 @@ function getOAuthService() { * @return {HtmlOutput} The HTML output to show to the user. */ function authCallback(request) { - var authorized = getOAuthService().handleCallback(request); + const authorized = getOAuthService().handleCallback(request); if (authorized) { return HtmlService.createHtmlOutput('Success! You can close this tab.'); } else { @@ -270,22 +270,22 @@ function get3PAuthorizationUrls() { * @return {object} An object with an errorCode. */ function setCredentials(request) { - var creds = request.userPass; - var path = creds.path; - var username = creds.username; - var password = creds.password; + const creds = request.userPass; + const path = creds.path; + const username = creds.username; + const password = creds.password; // Optional // Check if the provided path, username and password are valid through // a call to your service. You would have to have a `checkForValidCreds` // function defined for this to work. - var validCreds = checkForValidCreds(path, username, password); + const validCreds = checkForValidCreds(path, username, password); if (!validCreds) { return { errorCode: 'INVALID_CREDENTIALS' }; } - var userProperties = PropertiesService.getUserProperties(); + const userProperties = PropertiesService.getUserProperties(); userProperties.setProperty('dscc.path', path); userProperties.setProperty('dscc.username', username); userProperties.setProperty('dscc.password', password); @@ -302,21 +302,21 @@ function setCredentials(request) { * @return {object} An object with an errorCode. */ function setCredentials(request) { - var creds = request.userPass; - var username = creds.username; - var password = creds.password; + const creds = request.userPass; + const username = creds.username; + const password = creds.password; // Optional // Check if the provided username and password are valid through a // call to your service. You would have to have a `checkForValidCreds` // function defined for this to work. - var validCreds = checkForValidCreds(username, password); + const validCreds = checkForValidCreds(username, password); if (!validCreds) { return { errorCode: 'INVALID_CREDENTIALS' }; } - var userProperties = PropertiesService.getUserProperties(); + const userProperties = PropertiesService.getUserProperties(); userProperties.setProperty('dscc.username', username); userProperties.setProperty('dscc.password', password); return { @@ -332,21 +332,21 @@ function setCredentials(request) { * @return {object} An object with an errorCode. */ function setCredentials(request) { - var creds = request.userToken; - var username = creds.username; - var token = creds.token; + const creds = request.userToken; + const username = creds.username; + const token = creds.token; // Optional // Check if the provided username and token are valid through a // call to your service. You would have to have a `checkForValidCreds` // function defined for this to work. - var validCreds = checkForValidCreds(username, token); + const validCreds = checkForValidCreds(username, token); if (!validCreds) { return { errorCode: 'INVALID_CREDENTIALS' }; } - var userProperties = PropertiesService.getUserProperties(); + const userProperties = PropertiesService.getUserProperties(); userProperties.setProperty('dscc.username', username); userProperties.setProperty('dscc.token', token); return { @@ -362,19 +362,19 @@ function setCredentials(request) { * @return {object} An object with an errorCode. */ function setCredentials(request) { - var key = request.key; + const key = request.key; // Optional // Check if the provided key is valid through a call to your service. // You would have to have a `checkForValidKey` function defined for // this to work. - var validKey = checkForValidKey(key); + const validKey = checkForValidKey(key); if (!validKey) { return { errorCode: 'INVALID_CREDENTIALS' }; } - var userProperties = PropertiesService.getUserProperties(); + const userProperties = PropertiesService.getUserProperties(); userProperties.setProperty('dscc.key', key); return { errorCode: 'NONE' diff --git a/data-studio/build.gs b/data-studio/build.gs index ec845dba6..82a6d1299 100644 --- a/data-studio/build.gs +++ b/data-studio/build.gs @@ -21,8 +21,8 @@ * @see https://developers.google.com/apps-script/reference/data-studio/config */ function getConfig() { - var cc = DataStudioApp.createCommunityConnector(); - var config = cc.getConfig(); + const cc = DataStudioApp.createCommunityConnector(); + const config = cc.getConfig(); config.newInfo() .setId('instructions') @@ -48,10 +48,10 @@ function getConfig() { * @see https://developers.google.com/apps-script/reference/data-studio/fields */ function getFields() { - var cc = DataStudioApp.createCommunityConnector(); - var fields = cc.getFields(); - var types = cc.FieldType; - var aggregations = cc.AggregationType; + const cc = DataStudioApp.createCommunityConnector(); + const fields = cc.getFields(); + const types = cc.FieldType; + const aggregations = cc.AggregationType; fields.newDimension() .setId('packageName') @@ -78,7 +78,7 @@ function getFields() { * @return {object} The schema. */ function getSchema(request) { - var fields = getFields().build(); + const fields = getFields().build(); return {'schema': fields}; } // [END apps_script_data_studio_build_get_fields] @@ -94,7 +94,7 @@ function getSchema(request) { function responseToRows(requestedFields, response, packageName) { // Transform parsed data and filter for requested fields return response.map(function(dailyDownload) { - var row = []; + let row = []; requestedFields.asArray().forEach(function(field) { switch (field.getId()) { case 'day': @@ -117,13 +117,13 @@ function responseToRows(requestedFields, response, packageName) { * @return {object} The data. */ function getData(request) { - var requestedFieldIds = request.fields.map(function(field) { + const requestedFieldIds = request.fields.map(function(field) { return field.name; }); - var requestedFields = getFields().forIds(requestedFieldIds); + const requestedFields = getFields().forIds(requestedFieldIds); // Fetch and parse data from API - var url = [ + const url = [ 'https://api.npmjs.org/downloads/range/', request.dateRange.startDate, ':', @@ -131,9 +131,9 @@ function getData(request) { '/', request.configParams.package ]; - var response = UrlFetchApp.fetch(url.join('')); - var parsedResponse = JSON.parse(response).downloads; - var rows = responseToRows(requestedFields, parsedResponse, request.configParams.package); + const response = UrlFetchApp.fetch(url.join('')); + const parsedResponse = JSON.parse(response).downloads; + const rows = responseToRows(requestedFields, parsedResponse, request.configParams.package); return { schema: requestedFields.build(), @@ -148,7 +148,7 @@ function getData(request) { * @return {object} The auth type. */ function getAuthType() { - var cc = DataStudioApp.createCommunityConnector(); + const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse() .setAuthType(cc.AuthType.NONE) .build(); diff --git a/data-studio/errors.gs b/data-studio/errors.gs index e6cdc1719..f48fd1a7c 100644 --- a/data-studio/errors.gs +++ b/data-studio/errors.gs @@ -57,7 +57,7 @@ function throwConnectorError(message, userSafe) { * the log entry. */ function logConnectorError(originalError, message) { - var logEntry = [ + const logEntry = [ 'Original error (Message): ', originalError, '(', message, ')' diff --git a/data-studio/semantics.gs b/data-studio/semantics.gs index 426eb47e1..8b6af6837 100644 --- a/data-studio/semantics.gs +++ b/data-studio/semantics.gs @@ -15,7 +15,7 @@ */ // [START apps_script_data_studio_manual] -var schema = [ +const schema = [ { 'name': 'Income', 'label': 'Income (in USD)', diff --git a/docs/quickstart/quickstart.gs b/docs/quickstart/quickstart.gs index ea514fc00..5ef105882 100644 --- a/docs/quickstart/quickstart.gs +++ b/docs/quickstart/quickstart.gs @@ -21,14 +21,7 @@ */ function printDocTitle() { const documentId = '195j9eDD3ccgjQRttHhJPymLJUCOUjs-jmwTrekvdjFE'; - try { - // Get the document using document id - const doc = Docs.Documents.get(documentId); - // Log the title of document. - console.log('The title of the doc is: %s', doc.title); - } catch (err) { - // TODO (developer) - Handle exception from Docs API - console.log('Failed to found document with an error %s', err.message); - } + const doc = Docs.Documents.get(documentId, {'includeTabsContent': true}); + console.log(`The title of the doc is: ${doc.title}`); } // [END docs_quickstart] diff --git a/forms-api/demos/AppsScriptFormsAPIWebApp/FormsAPI.gs b/forms-api/demos/AppsScriptFormsAPIWebApp/FormsAPI.gs index 5be743b4e..6e36d003c 100644 --- a/forms-api/demos/AppsScriptFormsAPIWebApp/FormsAPI.gs +++ b/forms-api/demos/AppsScriptFormsAPIWebApp/FormsAPI.gs @@ -42,7 +42,7 @@ function create(title) { }; console.log('Forms API POST options was: ' + JSON.stringify(options)); - let response = UrlFetchApp.fetch(formsAPIUrl, options); + const response = UrlFetchApp.fetch(formsAPIUrl, options); console.log('Response from Forms API was: ' + JSON.stringify(response)); return ('' + response); @@ -64,7 +64,7 @@ function get(formId) { }; try { - let response = UrlFetchApp.fetch(formsAPIUrl + formId, options); + const response = UrlFetchApp.fetch(formsAPIUrl + formId, options); console.log('Response from Forms API was: ' + response); return ('' + response); } catch (e) { @@ -103,7 +103,7 @@ function batchUpdate(formId) { 'muteHttpExceptions': true, }; - let response = UrlFetchApp.fetch(formsAPIUrl + formId + ':batchUpdate', + const response = UrlFetchApp.fetch(formsAPIUrl + formId + ':batchUpdate', options); console.log('Response code from API: ' + response.getResponseCode()); return (response.getResponseCode()); @@ -116,7 +116,7 @@ function batchUpdate(formId) { function responsesGet(formId, responseId) { const accessToken = ScriptApp.getOAuthToken(); - var options = { + const options = { 'headers': { Authorization: 'Bearer ' + accessToken, Accept: 'application/json' @@ -125,7 +125,7 @@ function responsesGet(formId, responseId) { }; try { - var response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'responses/' + + const response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'responses/' + responseId, options); console.log('Response from Forms.responses.get was: ' + response); return ('' + response); @@ -142,7 +142,7 @@ function responsesGet(formId, responseId) { function responsesList(formId) { const accessToken = ScriptApp.getOAuthToken(); - var options = { + const options = { 'headers': { Authorization: 'Bearer ' + accessToken, Accept: 'application/json' @@ -151,7 +151,7 @@ function responsesList(formId) { }; try { - var response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'responses', + const response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'responses', options); console.log('Response from Forms.responses was: ' + response); return ('' + response); @@ -166,9 +166,9 @@ function responsesList(formId) { * POST https://forms.googleapis.com/v1/forms/{formId}/watches */ function createWatch(formId) { - let accessToken = ScriptApp.getOAuthToken(); + const accessToken = ScriptApp.getOAuthToken(); - var myWatch = { + const myWatch = { 'watch': { 'target': { 'topic': { @@ -180,7 +180,7 @@ function createWatch(formId) { }; console.log('myWatch is: ' + JSON.stringify(myWatch)); - var options = { + const options = { 'headers': { Authorization: 'Bearer ' + accessToken }, @@ -192,7 +192,7 @@ function createWatch(formId) { console.log('options are: ' + JSON.stringify(options)); console.log('formsAPIURL was: ' + formsAPIUrl); - var response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'watches', + const response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'watches', options); console.log(response); return ('' + response); @@ -203,11 +203,11 @@ function createWatch(formId) { * DELETE https://forms.googleapis.com/v1/forms/{formId}/watches/{watchId} */ function deleteWatch(formId, watchId) { - let accessToken = ScriptApp.getOAuthToken(); + const accessToken = ScriptApp.getOAuthToken(); console.log('formsAPIUrl is: ' + formsAPIUrl); - var options = { + const options = { 'headers': { Authorization: 'Bearer ' + accessToken, Accept: 'application/json' @@ -217,7 +217,7 @@ function deleteWatch(formId, watchId) { }; try { - var response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'watches/' + + const response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'watches/' + watchId, options); console.log(response); return ('' + response); @@ -234,8 +234,8 @@ function deleteWatch(formId, watchId) { */ function watchesList(formId) { console.log('formId is: ' + formId); - let accessToken = ScriptApp.getOAuthToken(); - var options = { + const accessToken = ScriptApp.getOAuthToken(); + const options = { 'headers': { Authorization: 'Bearer ' + accessToken, Accept: 'application/json' @@ -243,7 +243,7 @@ function watchesList(formId) { 'method': 'get' }; try { - var response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'watches', + const response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'watches', options); console.log(response); return ('' + response); @@ -258,9 +258,9 @@ function watchesList(formId) { * POST https://forms.googleapis.com/v1/forms/{formId}/watches/{watchId}:renew */ function renewWatch(formId, watchId) { - let accessToken = ScriptApp.getOAuthToken(); + const accessToken = ScriptApp.getOAuthToken(); - var options = { + const options = { 'headers': { Authorization: 'Bearer ' + accessToken, Accept: 'application/json' @@ -269,7 +269,7 @@ function renewWatch(formId, watchId) { }; try { - var response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'watches/' + + const response = UrlFetchApp.fetch(formsAPIUrl + formId + '/' + 'watches/' + watchId + ':renew', options); console.log(response); return ('' + response); diff --git a/forms-api/demos/AppsScriptFormsAPIWebApp/Main.html b/forms-api/demos/AppsScriptFormsAPIWebApp/Main.html index 8612e1b37..b14d484bb 100644 --- a/forms-api/demos/AppsScriptFormsAPIWebApp/Main.html +++ b/forms-api/demos/AppsScriptFormsAPIWebApp/Main.html @@ -97,9 +97,9 @@ } function create() { - var status = document.getElementById('status'); + const status = document.getElementById('status'); status.innerHTML = "Creating new form..."; - var newFormTitle = document.getElementById('newFormTitle'); + const newFormTitle = document.getElementById('newFormTitle'); google.script.run .withFailureHandler(function(error) { @@ -119,9 +119,9 @@ } function get() { - var status = document.getElementById('status'); + const status = document.getElementById('status'); status.innerHTML = "Get form by id..."; - var formId = document.getElementById('globalFormId'); + const formId = document.getElementById('globalFormId'); google.script.run .withFailureHandler(function(error) { @@ -135,9 +135,9 @@ } function batchUpdate() { - var status = document.getElementById('status'); + const status = document.getElementById('status'); status.innerHTML = "Batch update..."; - var formId = document.getElementById('globalFormId'); + const formId = document.getElementById('globalFormId'); google.script.run .withFailureHandler(function(error) { @@ -153,9 +153,9 @@ function responsesList() { - var status = document.getElementById('status'); + const status = document.getElementById('status'); status.innerHTML = "Get form by id..."; - var formId = document.getElementById('globalFormId'); + const formId = document.getElementById('globalFormId'); google.script.run .withFailureHandler(function(error) { @@ -169,10 +169,10 @@ } function responsesGet() { - var status = document.getElementById('status'); + const status = document.getElementById('status'); status.innerHTML = "Get response by id..."; - var formId = document.getElementById('globalFormId'); - var respId = document.getElementById('responseId'); + const formId = document.getElementById('globalFormId'); + const respId = document.getElementById('responseId'); google.script.run .withFailureHandler(function(error) { @@ -186,9 +186,9 @@ } function watchesList() { - var status = document.getElementById('status'); + const status = document.getElementById('status'); status.innerHTML = "Get watches ..."; - var formId = document.getElementById('globalFormId'); + const formId = document.getElementById('globalFormId'); google.script.run .withFailureHandler(function(error) { @@ -202,9 +202,9 @@ } function createWatch() { - var status = document.getElementById('status'); + const status = document.getElementById('status'); status.innerHTML = "Create watch ..."; - var formId = document.getElementById('globalFormId'); + const formId = document.getElementById('globalFormId'); google.script.run .withFailureHandler(function(error) { @@ -218,10 +218,10 @@ } function deleteWatch() { - var status = document.getElementById('status'); + const status = document.getElementById('status'); status.innerHTML = "Delete watch ..."; - var formId = document.getElementById('globalFormId'); - var watchId = document.getElementById('watchId'); + const formId = document.getElementById('globalFormId'); + const watchId = document.getElementById('watchId'); google.script.run .withFailureHandler(function(error) { @@ -235,10 +235,10 @@ } function renewWatch() { - var status = document.getElementById('status'); + const status = document.getElementById('status'); status.innerHTML = "Renew watch ..."; - var formId = document.getElementById('globalFormId'); - var watchId = document.getElementById('renewWatchId'); + const formId = document.getElementById('globalFormId'); + const watchId = document.getElementById('renewWatchId'); google.script.run .withFailureHandler(function(error) { diff --git a/forms-api/snippets/retrieve_all_responses.gs b/forms-api/snippets/retrieve_all_responses.gs index 78a55ceeb..c7d865e6d 100644 --- a/forms-api/snippets/retrieve_all_responses.gs +++ b/forms-api/snippets/retrieve_all_responses.gs @@ -15,21 +15,21 @@ # [START forms_retrieve_all_responses] function callFormsAPI() { console.log('Calling the Forms API!'); - var formId = ''; + const formId = ''; // Get OAuth Token - var OAuthToken = ScriptApp.getOAuthToken(); + const OAuthToken = ScriptApp.getOAuthToken(); console.log('OAuth token is: ' + OAuthToken); - var formsAPIUrl = 'https://forms.googleapis.com/v1/forms/' + formId + '/' + 'responses'; + const formsAPIUrl = 'https://forms.googleapis.com/v1/forms/' + formId + '/' + 'responses'; console.log('formsAPIUrl is: ' + formsAPIUrl); - var options = { + const options = { 'headers': { Authorization: 'Bearer ' + OAuthToken, Accept: 'application/json' }, 'method': 'get' }; -var response = UrlFetchApp.fetch(formsAPIUrl, options); +const response = UrlFetchApp.fetch(formsAPIUrl, options); console.log('Response from forms.responses was: ' + response); } # [END forms_retrieve_all_responses] diff --git a/gmail-sentiment-analysis/.clasp.json b/gmail-sentiment-analysis/.clasp.json new file mode 100644 index 000000000..8206fa91c --- /dev/null +++ b/gmail-sentiment-analysis/.clasp.json @@ -0,0 +1,3 @@ +{ + "scriptId": "1Z2gfvr0oYn68ppDtQbv0qIuKKVWhvwOTr-gCE0GFKVjNk8NDlpfJAGAr" +} diff --git a/gmail-sentiment-analysis/Cards.gs b/gmail-sentiment-analysis/Cards.gs new file mode 100644 index 000000000..1c1fe6df4 --- /dev/null +++ b/gmail-sentiment-analysis/Cards.gs @@ -0,0 +1,107 @@ +/* +Copyright 2024-2025 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Builds the main card displayed on the Gmail homepage. + * + * @returns {Card} - The homepage card. + */ +function buildHomepageCard() { + // Create a new card builder + const cardBuilder = CardService.newCardBuilder(); + + // Create a card header + const cardHeader = CardService.newCardHeader(); + cardHeader.setImageUrl('https://fonts.gstatic.com/s/i/googlematerialicons/mail/v6/black-24dp/1x/gm_mail_black_24dp.png'); + cardHeader.setImageStyle(CardService.ImageStyle.CIRCLE); + cardHeader.setTitle("Analyze your Gmail"); + + // Add the header to the card + cardBuilder.setHeader(cardHeader); + + // Create a card section + const cardSection = CardService.newCardSection(); + + // Create buttons for generating sample emails and analyzing sentiment + const buttonSet = CardService.newButtonSet(); + + // Create "Generate sample emails" button + const generateButton = createFilledButton('Generate sample emails', 'generateSampleEmails', '#34A853'); + buttonSet.addButton(generateButton); + + // Create "Analyze emails" button + const analyzeButton = createFilledButton('Analyze emails', 'analyzeSentiment', '#FF0000'); + buttonSet.addButton(analyzeButton); + + // Add the button set to the section + cardSection.addWidget(buttonSet); + + // Add the section to the card + cardBuilder.addSection(cardSection); + + // Build and return the card + return cardBuilder.build(); +} + +/** + * Creates a filled text button with the specified text, function, and color. + * + * @param {string} text - The text to display on the button. + * @param {string} functionName - The name of the function to call when the button is clicked. + * @param {string} color - The background color of the button. + * @returns {TextButton} - The created text button. + */ +function createFilledButton(text, functionName, color) { + // Create a new text button + const textButton = CardService.newTextButton(); + + // Set the button text + textButton.setText(text); + + // Set the action to perform when the button is clicked + const action = CardService.newAction(); + action.setFunctionName(functionName); + textButton.setOnClickAction(action); + + // Set the button style to filled + textButton.setTextButtonStyle(CardService.TextButtonStyle.FILLED); + + // Set the background color + textButton.setBackgroundColor(color); + + return textButton; +} + +/** + * Creates a notification response with the specified text. + * + * @param {string} notificationText - The text to display in the notification. + * @returns {ActionResponse} - The created action response. + */ +function buildNotificationResponse(notificationText) { + // Create a new notification + const notification = CardService.newNotification(); + notification.setText(notificationText); + + // Create a new action response builder + const actionResponseBuilder = CardService.newActionResponseBuilder(); + + // Set the notification for the action response + actionResponseBuilder.setNotification(notification); + + // Build and return the action response + return actionResponseBuilder.build(); +} \ No newline at end of file diff --git a/gmail-sentiment-analysis/Code.gs b/gmail-sentiment-analysis/Code.gs new file mode 100644 index 000000000..c803c5690 --- /dev/null +++ b/gmail-sentiment-analysis/Code.gs @@ -0,0 +1,25 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Triggered when the add-on is opened from the Gmail homepage. + * + * @param {Object} e - The event object. + * @returns {Card} - The homepage card. + */ +function onHomepageTrigger(e) { + return buildHomepageCard(); +} \ No newline at end of file diff --git a/gmail-sentiment-analysis/Gmail.gs b/gmail-sentiment-analysis/Gmail.gs new file mode 100644 index 000000000..593ee3ba1 --- /dev/null +++ b/gmail-sentiment-analysis/Gmail.gs @@ -0,0 +1,109 @@ +/* +Copyright 2024-2025 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Analyzes the sentiment of the first 10 threads in the inbox + * and labels them accordingly. + * + * @returns {ActionResponse} - A notification confirming completion. + */ +function analyzeSentiment() { + // Analyze and label emails + analyzeAndLabelEmailSentiment(); + + // Return a notification + return buildNotificationResponse("Successfully completed sentiment analysis"); +} + +/** + * Analyzes the sentiment of emails and applies appropriate labels. + */ +function analyzeAndLabelEmailSentiment() { + // Define label names + const labelNames = ["HAPPY TONE 😊", "NEUTRAL TONE 😐", "UPSET TONE 😡"]; + + // Get or create labels for each sentiment + const positiveLabel = GmailApp.getUserLabelByName(labelNames[0]) || GmailApp.createLabel(labelNames[0]); + const neutralLabel = GmailApp.getUserLabelByName(labelNames[1]) || GmailApp.createLabel(labelNames[1]); + const negativeLabel = GmailApp.getUserLabelByName(labelNames[2]) || GmailApp.createLabel(labelNames[2]); + + // Get the first 10 threads in the inbox + const threads = GmailApp.getInboxThreads(0, 10); + + // Iterate through each thread + for (const thread of threads) { + // Iterate through each message in the thread + const messages = thread.getMessages(); + for (const message of messages) { + // Get the plain text body of the message + const emailBody = message.getPlainBody(); + + // Analyze the sentiment of the email body + const sentiment = processSentiment(emailBody); + + // Apply the appropriate label based on the sentiment + if (sentiment === 'positive') { + thread.addLabel(positiveLabel); + } else if (sentiment === 'neutral') { + thread.addLabel(neutralLabel); + } else if (sentiment === 'negative') { + thread.addLabel(negativeLabel); + } + } + } +} + +/** + * Generates sample emails for testing the sentiment analysis. + * + * @returns {ActionResponse} - A notification confirming email generation. + */ +function generateSampleEmails() { + // Get the current user's email address + const userEmail = Session.getActiveUser().getEmail(); + + // Define sample emails + const sampleEmails = [ + { + subject: 'Thank you for amazing service!', + body: 'Hi, I really enjoyed working with you. Thank you again!', + name: 'Customer A' + }, + { + subject: 'Request for information', + body: 'Hello, I need more information on your recent product launch. Thank you.', + name: 'Customer B' + }, + { + subject: 'Complaint!', + body: '', + htmlBody: `

Hello, You are late in delivery, again.

+

Please contact me ASAP before I cancel our subscription.

`, + name: 'Customer C' + } + ]; + + // Send each sample email + for (const email of sampleEmails) { + GmailApp.sendEmail(userEmail, email.subject, email.body, { + name: email.name, + htmlBody: email.htmlBody + }); + } + + // Return a notification + return buildNotificationResponse("Successfully generated sample emails"); +} \ No newline at end of file diff --git a/gmail-sentiment-analysis/README.md b/gmail-sentiment-analysis/README.md new file mode 100644 index 000000000..9e441e925 --- /dev/null +++ b/gmail-sentiment-analysis/README.md @@ -0,0 +1,91 @@ +# Gmail Sentiment Analysis with Gemini and Vertex AI + +This project guides you through building a Google Workspace Add-on that +leverages Gemini and Vertex AI for conducting sentiment analysis on emails in +Gmail. The add-on automatically identifies emails with different tones and +labels them accordingly, helping prioritize customer service responses or +identify potentially sensitive emails. + +> [!NOTE] +You can also run this lab on [https://www.cloudskillsboost.google/catalog_lab/31942](Cloud Skills Boost). + +## What you'll learn + +* Build a Google Workspace Add-on +* Integrate Vertex AI with Google Workspace +* Implement OAuth2 authentication +* Apply sentiment analysis +* Utilize Apps Script + +## Setup and Requirements + +* **Web Browser:** Chrome (recommended) +* **Dedicated Time:** Set aside uninterrupted time. +* **Incognito/Private Window:** **Important:** Use an incognito or private browsing window to prevent conflicts with your personal accounts. + +## Steps + +### Setup Google Cloud Platform + +1. Create a new project. +2. Associate a billing account with the project. +3. Enable the Vertex AI API. + +### Setup an Apps Script Project + +1. Navigate to [https://script.google.com](Apps Script homepage). +2. Click **New project**. +3. Rename the project to "Gmail Sentiment Analysis with Gemini and Vertex AI". +4. In Project Settings (gear icon), select "Show 'appsscript.json' manifest file in editor". +5. In Project Settings, under Google Cloud Platform (GCP) Project, click **Change project**. +6. Copy the **Project number** (numerical value, not Project ID) from Cloud Console. +7. Paste the Project number into the Apps Script project settings and click **Set project**. +8. Click the **OAuth Consent details** link in the error message. +9. Click **Configure Consent Screen**. +10. Click on **Get started** and follow the prompts to configure the consent screen as follows: + 1. Set the App name to "Gmail Sentiment Analysis with Gemini and Vertex AI". + 2. Set the User support email to your email. + 3. Select **Internal** for Audience. + 4. Set the email address under Contact Information to your email. + 5. Review and agree to the "Google API Services: User Data Policy". + 6. Click on **Create**. +11. Return to the Apps Script tab and set the project number again. You should not get an error this time. + +### Populate the Apps Script project with code + +1. Replace the content of `appsscript.json` and `Code.gs` with the code from this repo. +2. Create new files (`Cards`, `Gmail`, `Vertex`) and replace the contect with the relevant code from this repo. +3. Open the `Vertex.gs` file and replace the `PROJECT_ID` value with your Google Cloud project ID. +4. Make sure to save the content before proceeding. + +### Deploy the Add-on + +1. On the Apps Script screen, click **Deploy > Test deployments**. +2. Confirm **Gmail** is listed under Application(s) and click **Install**. +3. Click **Done**. + +### Verify Installation + +Open [https://mail.google.com/](Gmail) and expand the right side panel. You should see a new add-on icon in the right side panel. + +**Troubleshooting:** + +* Refresh the browser if the add-on isn't visible. +* Uninstall and reinstall the add-on from the Test deployments window if it's still missing. + +### Run the Add-on + +1. **Open the Add-on:** Click the add-on icon in the Gmail side panel. +2. **Authorize the Add-on:** Grant the necessary permissions for the add-on to access your inbox and connect with Vertex AI. +3. **Generate sample emails:** Click the green "Generate sample emails" button. +4. **Wait for emails:** Wait for the sample emails to appear in your inbox, or refresh your inbox. +5. **Start the analysis:** Click the red "Analyze emails" button. +6. **Wait for labels:** Wait for the "UPSET TONE 😡" label to appear on negative emails, or refresh. +7. **Close the Add-on:** Click the X in the top right corner of the side panel. + + +## Congratulations! + +You've completed the Gmail Sentiment Analysis with Gemini and Vertex AI lab! +You now have a functional Gmail add-on for prioritizing emails. Experiment +further by customizing the sentiment analysis or adding new features! diff --git a/gmail-sentiment-analysis/Vertex.gs b/gmail-sentiment-analysis/Vertex.gs new file mode 100644 index 000000000..959ebdc52 --- /dev/null +++ b/gmail-sentiment-analysis/Vertex.gs @@ -0,0 +1,85 @@ +/* +Copyright 2024-2025 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Replace with your project ID +const PROJECT_ID = '[ADD YOUR GCP PROJECT ID HERE]'; + +// Location for your Vertex AI model +const VERTEX_AI_LOCATION = 'us-central1'; + +// Model ID to use for sentiment analysis +const MODEL_ID = 'gemini-2.5-flash'; + +/** + * Sends the email text to Vertex AI for sentiment analysis. + * + * @param {string} emailText - The text of the email to analyze. + * @returns {string} - The sentiment of the email ('positive', 'negative', or 'neutral'). + */ +function processSentiment(emailText) { + // Construct the API endpoint URL + const apiUrl = `https://${VERTEX_AI_LOCATION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${VERTEX_AI_LOCATION}/publishers/google/models/${MODEL_ID}:generateContent`; + + // Prepare the request payload + const payload = { + contents: [ + { + role: "user", + parts: [ + { + text: `Analyze the sentiment of the following message: ${emailText}` + } + ] + } + ], + generationConfig: { + temperature: 0.9, + maxOutputTokens: 1024, + responseMimeType: "application/json", + // Expected response format for simpler parsing. + responseSchema: { + type: "object", + properties: { + response: { + type: "string", + enum: ["positive", "negative", "neutral"] + } + } + } + } + }; + + // Prepare the request options + const options = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${ScriptApp.getOAuthToken()}` + }, + contentType: 'application/json', + muteHttpExceptions: true, // Set to true to inspect the error response + payload: JSON.stringify(payload) + }; + + // Make the API request + const response = UrlFetchApp.fetch(apiUrl, options); + + // Parse the response. There are two levels of JSON responses to parse. + const parsedResponse = JSON.parse(response.getContentText()); + const sentimentResponse = JSON.parse(parsedResponse.candidates[0].content.parts[0].text).response; + + // Return the sentiment + return sentimentResponse; +} \ No newline at end of file diff --git a/gmail-sentiment-analysis/appsscript.json b/gmail-sentiment-analysis/appsscript.json new file mode 100644 index 000000000..c1085b5f1 --- /dev/null +++ b/gmail-sentiment-analysis/appsscript.json @@ -0,0 +1,25 @@ +{ + "timeZone": "America/Toronto", + "oauthScopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/gmail.addons.execute", + "https://www.googleapis.com/auth/gmail.labels", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/script.external_request", + "https://www.googleapis.com/auth/userinfo.email" + ], + "addOns": { + "common": { + "name": "Sentiment Analysis", + "logoUrl": "https://fonts.gstatic.com/s/i/googlematerialicons/sentiment_extremely_dissatisfied/v6/black-24dp/1x/gm_sentiment_extremely_dissatisfied_black_24dp.png" + }, + "gmail": { + "homepageTrigger": { + "runFunction": "onHomepageTrigger", + "enabled": true + } + } + }, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8" +} \ No newline at end of file diff --git a/picker/README.md b/picker/README.md index d8dfd7b26..4efb72ea3 100644 --- a/picker/README.md +++ b/picker/README.md @@ -1,12 +1,5 @@ # File Picker Sample -This sample shows how to create a "file-open" dialog in Google Sheets that -allows the user to select a file from their Drive. It does so by loading -[Google Picker](https://developers.google.com/picker/), a standard G Suite -client-side API for this purpose. More information is available in the Apps -Script guide -[Dialogs and Sidebars in Google Workspace Documents](https://developers.google.com/apps-script/guides/dialogs#file-open_dialogs). +This sample shows how to create a "file-open" dialog in Google Sheets thatallows the user to select a file from their Drive. It does so by loading [Google Picker](https://developers.google.com/picker/), for this purpose. More information is available in the Apps Script guide [Dialogs and Sidebars in Google Workspace Documents](https://developers.google.com/apps-script/guides/dialogs#file-open_dialogs). -Note that this sample expects to be -[bound](https://developers.google.com/apps-script/guides/bound) -to a spreadsheet. +Note that this sample expects to be [bound](https://developers.google.com/apps-script/guides/bound) to a spreadsheet. diff --git a/picker/appsscript.json b/picker/appsscript.json new file mode 100644 index 000000000..bc86e7be2 --- /dev/null +++ b/picker/appsscript.json @@ -0,0 +1,18 @@ +{ + "timeZone": "America/Los_Angeles", + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8", + "oauthScopes": [ + "https://www.googleapis.com/auth/script.container.ui", + "https://www.googleapis.com/auth/drive.file" + ], + "dependencies": { + "enabledAdvancedServices": [ + { + "userSymbol": "Drive", + "version": "v3", + "serviceId": "drive" + } + ] + } + } \ No newline at end of file diff --git a/picker/code.gs b/picker/code.gs index e373d62d9..3346d4214 100644 --- a/picker/code.gs +++ b/picker/code.gs @@ -19,14 +19,10 @@ * Creates a custom menu in Google Sheets when the spreadsheet opens. */ function onOpen() { - try { - SpreadsheetApp.getUi().createMenu('Picker') - .addItem('Start', 'showPicker') - .addToUi(); - } catch (e) { - // TODO (Developer) - Handle exception - console.log('Failed with error: %s', e.error); - } + SpreadsheetApp.getUi() + .createMenu("Picker") + .addItem("Start", "showPicker") + .addToUi(); } /** @@ -34,16 +30,17 @@ function onOpen() { * JavaScript code for the Google Picker API. */ function showPicker() { - try { - const html = HtmlService.createHtmlOutputFromFile('dialog.html') - .setWidth(600) - .setHeight(425) - .setSandboxMode(HtmlService.SandboxMode.IFRAME); - SpreadsheetApp.getUi().showModalDialog(html, 'Select a file'); - } catch (e) { - // TODO (Developer) - Handle exception - console.log('Failed with error: %s', e.error); - } + const html = HtmlService.createHtmlOutputFromFile("dialog.html") + .setWidth(800) + .setHeight(600) + .setSandboxMode(HtmlService.SandboxMode.IFRAME); + SpreadsheetApp.getUi().showModalDialog(html, "Select a file"); +} +/** + * Checks that the file can be accessed. + */ +function getFile(fileId) { + return Drive.Files.get(fileId, { fields: "*" }); } /** @@ -57,12 +54,6 @@ function showPicker() { * @return {string} The user's OAuth 2.0 access token. */ function getOAuthToken() { - try { - DriveApp.getRootFolder(); - return ScriptApp.getOAuthToken(); - } catch (e) { - // TODO (Developer) - Handle exception - console.log('Failed with error: %s', e.error); - } + return ScriptApp.getOAuthToken(); } // [END picker_code] diff --git a/picker/dialog.html b/picker/dialog.html index 4ba2aaa7d..9d85af9eb 100644 --- a/picker/dialog.html +++ b/picker/dialog.html @@ -13,106 +13,175 @@ - - - + - /** - * Displays an error message within the #result element. - * - * @param {string} message The error message to display. - */ - function showError(message) { - document.getElementById('result').innerHTML = 'Error: ' + message; - } - - - -
- -

-
- - + +
+ +
+
+ + diff --git a/slides/SpeakerNotesScript/scriptGen.gs b/slides/SpeakerNotesScript/scriptGen.gs index 7e4841d57..d89938f84 100644 --- a/slides/SpeakerNotesScript/scriptGen.gs +++ b/slides/SpeakerNotesScript/scriptGen.gs @@ -42,25 +42,25 @@ function onOpen(e) { * with the speaker notes for each slide. */ function generateSlidesScript() { - var presentation = SlidesApp.getActivePresentation(); - var docTitle = presentation.getName() + ' Script'; - var slides = presentation.getSlides(); + const presentation = SlidesApp.getActivePresentation(); + const docTitle = presentation.getName() + ' Script'; + const slides = presentation.getSlides(); // Creates a document in the user's home Drive directory. - var speakerNotesDoc = DocumentApp.create(docTitle); + const speakerNotesDoc = DocumentApp.create(docTitle); console.log('Created document with id %s', speakerNotesDoc.getId()); - var docBody = speakerNotesDoc.getBody(); - var header = docBody.appendParagraph(docTitle); + const docBody = speakerNotesDoc.getBody(); + const header = docBody.appendParagraph(docTitle); header.setHeading(DocumentApp.ParagraphHeading.HEADING1); // Iterate through each slide and extract the speaker notes // into the document body. - for (var i = 0; i < slides.length; i++) { - var section = docBody.appendParagraph('Slide ' + (i + 1)) + for (let i = 0; i < slides.length; i++) { + const section = docBody.appendParagraph('Slide ' + (i + 1)) .setHeading(DocumentApp.ParagraphHeading.HEADING2); - var notes = slides[i].getNotesPage().getSpeakerNotesShape().getText().asString(); + const notes = slides[i].getNotesPage().getSpeakerNotesShape().getText().asString(); docBody.appendParagraph(notes) .appendHorizontalRule(); } diff --git a/slides/imageSlides/imageSlides.gs b/slides/imageSlides/imageSlides.gs index 32dbd1b18..3214fa630 100644 --- a/slides/imageSlides/imageSlides.gs +++ b/slides/imageSlides/imageSlides.gs @@ -15,8 +15,8 @@ */ // [START apps_script_slides_image_create] -var NAME = 'My favorite images'; -var deck = SlidesApp.create(NAME); +const NAME = 'My favorite images'; +const deck = SlidesApp.create(NAME); // [END apps_script_slides_image_create] // [START apps_script_slides_image_add_image] @@ -26,8 +26,8 @@ var deck = SlidesApp.create(NAME); * @param {number} index The slide index to add the image to */ function addImageSlide(imageUrl, index) { - var slide = deck.appendSlide(SlidesApp.PredefinedLayout.BLANK); - var image = slide.insertImage(imageUrl); + const slide = deck.appendSlide(SlidesApp.PredefinedLayout.BLANK); + const image = slide.insertImage(imageUrl); } // [END apps_script_slides_image_add_image] @@ -36,14 +36,14 @@ var deck = SlidesApp.create(NAME); * Adds images to a slides presentation. */ function main() { - var images = [ + const images = [ 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png', 'http://www.google.com/services/images/phone-animation-results_2x.png', 'http://www.google.com/services/images/section-work-card-img_2x.jpg', 'http://gsuite.google.com/img/icons/product-lockup.png', 'http://gsuite.google.com/img/home-hero_2x.jpg' ]; - var [title, subtitle] = deck.getSlides()[0].getPageElements(); + const [title, subtitle] = deck.getSlides()[0].getPageElements(); title.asShape().getText().setText(NAME); subtitle.asShape().getText().setText('Google Apps Script\nSlides Service demo'); images.forEach(addImageSlide); @@ -58,22 +58,22 @@ function main() { * @param {number} index The index into the array; unused (req'd by forEach) */ function addImageSlide(imageUrl, index) { - var slide = deck.appendSlide(SlidesApp.PredefinedLayout.BLANK); - var image = slide.insertImage(imageUrl); - var imgWidth = image.getWidth(); - var imgHeight = image.getHeight(); - var pageWidth = deck.getPageWidth(); - var pageHeight = deck.getPageHeight(); - var newX = pageWidth/2. - imgWidth/2.; - var newY = pageHeight/2. - imgHeight/2.; + const slide = deck.appendSlide(SlidesApp.PredefinedLayout.BLANK); + const image = slide.insertImage(imageUrl); + const imgWidth = image.getWidth(); + const imgHeight = image.getHeight(); + const pageWidth = deck.getPageWidth(); + const pageHeight = deck.getPageHeight(); + const newX = pageWidth/2. - imgWidth/2.; + const newY = pageHeight/2. - imgHeight/2.; image.setLeft(newX).setTop(newY); } // [END apps_script_slides_image_add_image_slide] // [START apps_script_slides_image_full_script] -var NAME = 'My favorite images'; -var presentation = SlidesApp.create(NAME); +const NAME = 'My favorite images'; +const presentation = SlidesApp.create(NAME); /** * Creates a single slide using the image from the given link; @@ -82,14 +82,14 @@ var presentation = SlidesApp.create(NAME); * @param {number} index The index into the array; unused (req'd by forEach) */ function addImageSlide(imageUrl, index) { - var slide = presentation.appendSlide(SlidesApp.PredefinedLayout.BLANK); - var image = slide.insertImage(imageUrl); - var imgWidth = image.getWidth(); - var imgHeight = image.getHeight(); - var pageWidth = presentation.getPageWidth(); - var pageHeight = presentation.getPageHeight(); - var newX = pageWidth/2. - imgWidth/2.; - var newY = pageHeight/2. - imgHeight/2.; + const slide = presentation.appendSlide(SlidesApp.PredefinedLayout.BLANK); + const image = slide.insertImage(imageUrl); + const imgWidth = image.getWidth(); + const imgHeight = image.getHeight(); + const pageWidth = presentation.getPageWidth(); + const pageHeight = presentation.getPageHeight(); + const newX = pageWidth/2. - imgWidth/2.; + const newY = pageHeight/2. - imgHeight/2.; image.setLeft(newX).setTop(newY); } @@ -98,14 +98,14 @@ function addImageSlide(imageUrl, index) { * main title & subtitle, and creation of individual slides for each image. */ function main() { - var images = [ + const images = [ 'http://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png', 'http://www.google.com/services/images/phone-animation-results_2x.png', 'http://www.google.com/services/images/section-work-card-img_2x.jpg', 'http://gsuite.google.com/img/icons/product-lockup.png', 'http://gsuite.google.com/img/home-hero_2x.jpg' ]; - var [title, subtitle] = presentation.getSlides()[0].getPageElements(); + const [title, subtitle] = presentation.getSlides()[0].getPageElements(); title.asShape().getText().setText(NAME); subtitle.asShape().getText().setText('Google Apps Script\nSlides Service demo'); images.forEach(addImageSlide); diff --git a/solutions/attendance-chat-app/README.md b/solutions/attendance-chat-app/README.md new file mode 100644 index 000000000..8404454da --- /dev/null +++ b/solutions/attendance-chat-app/README.md @@ -0,0 +1,23 @@ +# Attendance Chat App + +This code sample shows how to build a Google Chat app using Google +Apps Script. The Chat app responds to messages in a space or direct message (DM) and +allows the user to set a vacation responder in Gmail or add an all-day event to +their Calendar from Google Chat. + +## Usage + +You can follow [this codelab](https://developers.google.com/codelabs/chat-apps-script) +to build and test this Chat app. + +To use this Chat app, you must enable the Hangouts Chat API in the +[Google API Console](https://console.developers.google.com/). After enabling +the API, configuring the Chat app, and publishing it, you must add the Chat app to a space +or DM to begin a conversation. + +For more information about how to publish a Chat app, see +[Publishing Google Chat apps](https://developers.google.com/workspace/chat/apps-publish). + +## Disclaimer + +This is not an official product. diff --git a/solutions/attendance-chat-app/final/Code.gs b/solutions/attendance-chat-app/final/Code.gs new file mode 100644 index 000000000..c62c93a1c --- /dev/null +++ b/solutions/attendance-chat-app/final/Code.gs @@ -0,0 +1,160 @@ +/** + * Responds to an ADDED_TO_SPACE event in Google Chat. + * @param {object} event the event object from Google Chat + * @return {object} JSON-formatted response + * @see https://developers.google.com/workspace/chat/receive-respond-interactions + */ +function onAddToSpace(event) { + console.info(event); + var message = 'Thank you for adding me to '; + if (event.space.type === 'DM') { + message += 'a DM, ' + event.user.displayName + '!'; + } else { + message += event.space.displayName; + } + return { text: message }; +} + +/** + * Responds to a REMOVED_FROM_SPACE event in Google Chat. + * @param {object} event the event object from Google Chat + * @see https://developers.google.com/workspace/chat/receive-respond-interactions + */ +function onRemoveFromSpace(event) { + console.info(event); + console.log('Chat app removed from ', event.space.name); +} + +var DEFAULT_IMAGE_URL = 'https://goo.gl/bMqzYS'; +var HEADER = { + header: { + title : 'Attendance Chat app', + subtitle : 'Log your vacation time', + imageUrl : DEFAULT_IMAGE_URL + } +}; + +/** + * Creates a card-formatted response. + * @param {object} widgets the UI components to send + * @return {object} JSON-formatted response + */ +function createCardResponse(widgets) { + return { + cards: [HEADER, { + sections: [{ + widgets: widgets + }] + }] + }; +} + +var REASON = { + SICK: 'Out sick', + OTHER: 'Out of office' +}; +/** + * Responds to a MESSAGE event triggered in Google Chat. + * @param {object} event the event object from Google Chat + * @return {object} JSON-formatted response + */ +function onMessage(event) { + console.info(event); + var reason = REASON.OTHER; + var name = event.user.displayName; + var userMessage = event.message.text; + + // If the user said that they were 'sick', adjust the image in the + // header sent in response. + if (userMessage.indexOf('sick') > -1) { + // Hospital material icon + HEADER.header.imageUrl = 'https://goo.gl/mnZ37b'; + reason = REASON.SICK; + } else if (userMessage.indexOf('vacation') > -1) { + // Spa material icon + HEADER.header.imageUrl = 'https://goo.gl/EbgHuc'; + } + + var widgets = [{ + textParagraph: { + text: 'Hello, ' + name + '.
Are you taking time off today?' + } + }, { + buttons: [{ + textButton: { + text: 'Set vacation in Gmail', + onClick: { + action: { + actionMethodName: 'turnOnAutoResponder', + parameters: [{ + key: 'reason', + value: reason + }] + } + } + } + }, { + textButton: { + text: 'Block out day in Calendar', + onClick: { + action: { + actionMethodName: 'blockOutCalendar', + parameters: [{ + key: 'reason', + value: reason + }] + } + } + } + }] + }]; + return createCardResponse(widgets); +} + +/** + * Responds to a CARD_CLICKED event triggered in Google Chat. + * @param {object} event the event object from Google Chat + * @return {object} JSON-formatted response + * @see https://developers.google.com/workspace/chat/receive-respond-interactions + */ +function onCardClick(event) { + console.info(event); + var message = ''; + var reason = event.action.parameters[0].value; + if (event.action.actionMethodName == 'turnOnAutoResponder') { + turnOnAutoResponder(reason); + message = 'Turned on vacation settings.'; + } else if (event.action.actionMethodName == 'blockOutCalendar') { + blockOutCalendar(reason); + message = 'Blocked out your calendar for the day.'; + } else { + message = "I'm sorry; I'm not sure which button you clicked."; + } + return { text: message }; +} + +var ONE_DAY_MILLIS = 24 * 60 * 60 * 1000; +/** + * Turns on the user's vacation response for today in Gmail. + * @param {string} reason the reason for vacation, either REASON.SICK or REASON.OTHER + */ +function turnOnAutoResponder(reason) { + var currentTime = (new Date()).getTime(); + Gmail.Users.Settings.updateVacation({ + enableAutoReply: true, + responseSubject: reason, + responseBodyHtml: "I'm out of the office today; will be back on the next business day.

Created by Attendance Chat app!", + restrictToContacts: true, + restrictToDomain: true, + startTime: currentTime, + endTime: currentTime + ONE_DAY_MILLIS + }, 'me'); +} + +/** + * Places an all-day meeting on the user's Calendar. + * @param {string} reason the reason for vacation, either REASON.SICK or REASON.OTHER + */ +function blockOutCalendar(reason) { + CalendarApp.createAllDayEvent(reason, new Date(), new Date(Date.now() + ONE_DAY_MILLIS)); +} diff --git a/solutions/attendance-chat-app/final/appsscript.json b/solutions/attendance-chat-app/final/appsscript.json new file mode 100644 index 000000000..4482a3a30 --- /dev/null +++ b/solutions/attendance-chat-app/final/appsscript.json @@ -0,0 +1,12 @@ +{ + "timeZone": "America/Los_Angeles", + "dependencies": { + "enabledAdvancedServices": [{ + "userSymbol": "Gmail", + "serviceId": "gmail", + "version": "v1" + }] + }, + "chat": { + } +} \ No newline at end of file diff --git a/solutions/attendance-chat-app/step-3/Code.gs b/solutions/attendance-chat-app/step-3/Code.gs new file mode 100644 index 000000000..fc47fd758 --- /dev/null +++ b/solutions/attendance-chat-app/step-3/Code.gs @@ -0,0 +1,33 @@ +/** + * Responds to an ADDED_TO_SPACE event + * in Google Chat. + * + * @param event the event object from Google Chat + * @return JSON-formatted response + */ +function onAddToSpace(event) { + console.info(event); + + var message = ""; + + if (event.space.type === "DM") { + message = "Thank you for adding me to a DM, " + + event.user.displayName + "!"; + } else { + message = "Thank you for adding me to " + + event.space.displayName; + } + + return { "text": message }; +} + +/** + * Responds to a REMOVED_FROM_SPACE event + * in Google Chat. + * + * @param event the event object from Google Chat + */ +function onRemoveFromSpace(event) { + console.info(event); + console.info("Chat app removed from ", event.space.name); +} diff --git a/solutions/attendance-chat-app/step-3/appsscript.json b/solutions/attendance-chat-app/step-3/appsscript.json new file mode 100644 index 000000000..9e2a1d673 --- /dev/null +++ b/solutions/attendance-chat-app/step-3/appsscript.json @@ -0,0 +1,6 @@ +{ + "timeZone": "America/Los_Angeles", + "dependencies": { + }, + "chat": {} +} \ No newline at end of file diff --git a/solutions/attendance-chat-app/step-4/Code.gs b/solutions/attendance-chat-app/step-4/Code.gs new file mode 100644 index 000000000..fc47fd758 --- /dev/null +++ b/solutions/attendance-chat-app/step-4/Code.gs @@ -0,0 +1,33 @@ +/** + * Responds to an ADDED_TO_SPACE event + * in Google Chat. + * + * @param event the event object from Google Chat + * @return JSON-formatted response + */ +function onAddToSpace(event) { + console.info(event); + + var message = ""; + + if (event.space.type === "DM") { + message = "Thank you for adding me to a DM, " + + event.user.displayName + "!"; + } else { + message = "Thank you for adding me to " + + event.space.displayName; + } + + return { "text": message }; +} + +/** + * Responds to a REMOVED_FROM_SPACE event + * in Google Chat. + * + * @param event the event object from Google Chat + */ +function onRemoveFromSpace(event) { + console.info(event); + console.info("Chat app removed from ", event.space.name); +} diff --git a/solutions/attendance-chat-app/step-4/appsscript.json b/solutions/attendance-chat-app/step-4/appsscript.json new file mode 100644 index 000000000..9e2a1d673 --- /dev/null +++ b/solutions/attendance-chat-app/step-4/appsscript.json @@ -0,0 +1,6 @@ +{ + "timeZone": "America/Los_Angeles", + "dependencies": { + }, + "chat": {} +} \ No newline at end of file diff --git a/solutions/attendance-chat-app/step-5/Code.gs b/solutions/attendance-chat-app/step-5/Code.gs new file mode 100644 index 000000000..3aad2a4c5 --- /dev/null +++ b/solutions/attendance-chat-app/step-5/Code.gs @@ -0,0 +1,69 @@ +/** + * Responds to an ADDED_TO_SPACE event + * in Google Chat. + * + * @param event the event object from Google Chat + * @return JSON-formatted response + */ +function onAddToSpace(event) { + console.info(event); + + var message = ""; + + if (event.space.type === "DM") { + message = "Thank you for adding me to a DM, " + + event.user.displayName + "!"; + } else { + message = "Thank you for adding me to " + + event.space.displayName; + } + + return { "text": message }; +} + +/** + * Responds to a REMOVED_FROM_SPACE event + * in Google Chat. + * + * @param event the event object from Google Chat + */ +function onRemoveFromSpace(event) { + console.info(event); + console.info("Chat app removed from ", event.space.name); +} + +/** + * Creates a card-formatted response. + * + * @param widgets the UI components to send + * @return JSON-formatted response + */ +function createCardResponse(widgets) { + return { + "cards": [ + header, + { + "sections": [{ + "widgets": widgets + }] + }] + }; +} + +/** + * Responds to a MESSAGE event triggered in Google Chat. + * + * @param event the event object from Google Chat + * @return JSON-formatted response + */ +function onMessage(event) { + var userMessage = event.message.text; + + var widgets = [{ + "textParagraph": { + "text": "You said: " + userMessage + } + }]; + + return createCardResponse(widgets); +} \ No newline at end of file diff --git a/solutions/attendance-chat-app/step-5/appsscript.json b/solutions/attendance-chat-app/step-5/appsscript.json new file mode 100644 index 000000000..9e2a1d673 --- /dev/null +++ b/solutions/attendance-chat-app/step-5/appsscript.json @@ -0,0 +1,6 @@ +{ + "timeZone": "America/Los_Angeles", + "dependencies": { + }, + "chat": {} +} \ No newline at end of file diff --git a/solutions/attendance-chat-app/step-6/Code.gs b/solutions/attendance-chat-app/step-6/Code.gs new file mode 100644 index 000000000..84707a15f --- /dev/null +++ b/solutions/attendance-chat-app/step-6/Code.gs @@ -0,0 +1,183 @@ +/** + * Responds to an ADDED_TO_SPACE event + * in Google Chat. + * + * @param event the event object from Google Chat + * @return JSON-formatted response + */ +function onAddToSpace(event) { + console.info(event); + + var message = ""; + + if (event.space.type === "DM") { + message = "Thank you for adding me to a DM, " + + event.user.displayName + "!"; + } else { + message = "Thank you for adding me to " + + event.space.displayName; + } + + return { "text": message }; +} + +/** + * Responds to a REMOVED_FROM_SPACE event + * in Google Chat. + * + * @param event the event object from Google Chat + */ +function onRemoveFromSpace(event) { + console.info(event); + console.info("Chat app removed from ", event.space.name); +} + +var DEFAULT_IMAGE_URL = "https://goo.gl/bMqzYS"; +var header = { + "header": { + "title" : "Attendance Chat app", + "subtitle" : "Log your vacation time", + "imageUrl" : DEFAULT_IMAGE_URL + } +}; + +/** + * Creates a card-formatted response. + * + * @param widgets the UI components to send + * @return JSON-formatted response + */ +function createCardResponse(widgets) { + return { + "cards": [ + header, + { + "sections": [{ + "widgets": widgets + }] + }] + }; +} + +var REASON_SICK = "Out sick"; +var REASON_OTHER = "Out of office"; + +/** + * Responds to a MESSAGE event triggered in Google Chat. + * + * @param event the event object from Google Chat + * @return JSON-formatted response + */ +function onMessage(event) { + console.info(event); + + var reason = REASON_OTHER; + var name = event.user.displayName; + var userMessage = event.message.text; + + // If the user said that they were "sick", adjust the image in the + // header sent in response. + if (userMessage.indexOf("sick") > -1) { + + // Hospital material icon + header.header.imageUrl = "https://goo.gl/mnZ37b"; + reason = REASON_SICK; + + } else if (userMessage.indexOf("vacation") > -1) { + + // Spa material icon + header.header.imageUrl = "https://goo.gl/EbgHuc"; + } + + var widgets = [{ + "textParagraph": { + "text": "Hello, " + name + + ".
Are you taking time off today?" + } + }, { + "buttons": [{ + "textButton": { + "text": "Set vacation in Gmail", + "onClick": { + "action": { + "actionMethodName": "turnOnAutoResponder", + "parameters": [{ + "key": "reason", + "value": reason + }] + } + } + } + }, { + "textButton": { + "text": "Block out day in Calendar", + "onClick": { + "action": { + "actionMethodName": "blockOutCalendar", + "parameters": [{ + "key": "reason", + "value": reason + }] + } + } + } + }] + }]; + + return createCardResponse(widgets); +} + +/** + * Responds to a CARD_CLICKED event triggered in Google Chat. + * + * @param event the event object from Google Chat + * @return JSON-formatted response + */ +function onCardClick(event) { + console.info(event); + + var message = ""; + var reason = event.action.parameters[0].value; + + if (event.action.actionMethodName == "turnOnAutoResponder") { + turnOnAutoResponder(reason); + message = "Turned on vacation settings."; + } else if (event.action.actionMethodName == "blockOutCalendar") { + blockOutCalendar(reason); + message = "Blocked out your calendar for the day."; + } else { + message = "I'm sorry; I'm not sure which button you clicked."; + } + + return { "text": message }; +} + +var ONE_DAY_MILLIS = 24 * 60 * 60 * 1000; + +/** + * Turns on the user's vacation response for today in Gmail. + * + * @param reason the reason for vacation, either REASON_SICK or REASON_OTHER + */ +function turnOnAutoResponder(reason) { + var currentTime = (new Date()).getTime(); + + Gmail.Users.Settings.updateVacation({ + "enableAutoReply": true, + "responseSubject": reason, + "responseBodyHtml": "I'm out of the office today; will be back on the next business day.

Created by Attendance Chat app!", + "restrictToContacts": true, + "restrictToDomain": true, + "startTime": currentTime, + "endTime": currentTime + ONE_DAY_MILLIS + }, "me"); +} + +/** + * Places an all-day meeting on the user's Calendar. + * + * @param reason the reason for vacation, either REASON_SICK or REASON_OTHER + */ +function blockOutCalendar(reason) { + CalendarApp.createAllDayEvent(reason, new Date(), new Date(Date.now() + ONE_DAY_MILLIS)); +} diff --git a/solutions/attendance-chat-app/step-6/appsscript.json b/solutions/attendance-chat-app/step-6/appsscript.json new file mode 100644 index 000000000..4482a3a30 --- /dev/null +++ b/solutions/attendance-chat-app/step-6/appsscript.json @@ -0,0 +1,12 @@ +{ + "timeZone": "America/Los_Angeles", + "dependencies": { + "enabledAdvancedServices": [{ + "userSymbol": "Gmail", + "serviceId": "gmail", + "version": "v1" + }] + }, + "chat": { + } +} \ No newline at end of file diff --git a/solutions/ooo-assistant/.clasp.json b/solutions/ooo-assistant/.clasp.json new file mode 100644 index 000000000..747d3bffa --- /dev/null +++ b/solutions/ooo-assistant/.clasp.json @@ -0,0 +1 @@ +{"scriptId": "16L_UmGrkrDKYWrfw9YlnUnnnWOMBEWywyPrZDZIQqKF17Q97RtZeinqn"} diff --git a/solutions/ooo-assistant/Chat.gs b/solutions/ooo-assistant/Chat.gs new file mode 100644 index 000000000..0a8f3a59b --- /dev/null +++ b/solutions/ooo-assistant/Chat.gs @@ -0,0 +1,69 @@ +/** + * Copyright 2025 Google LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const APP_COMMAND = "app command"; + +/** + * Responds to an ADDED_TO_SPACE event in Google Chat. + * @param {Object} event the event object from Google Workspace Add On + */ +function onAddedToSpace(event) { + return sendCreateMessageAction(createCardMessage(help(APP_COMMAND))); +} + +/** + * Responds to a MESSAGE event in Google Chat. + * @param {Object} event the event object from Google Workspace Add On + */ +function onMessage(event) { + return sendCreateMessageAction(createCardMessage(help(APP_COMMAND))); +} + +/** + * Responds to a APP_COMMAND event in Google Chat. + * @param {Object} event the event object from Google Workspace Add On + */ +function onAppCommand(event) { + switch (event.chat.appCommandPayload.appCommandMetadata.appCommandId) { + case 2: // Block out day + return sendCreateMessageAction(createCardMessage(blockDayOut())); + case 3: // Set auto reply + return sendCreateMessageAction(createCardMessage(setAutoReply())); + default: // Help, any other + return sendCreateMessageAction(createCardMessage(help(APP_COMMAND))); + } +} + +/** + * Responds to a REMOVED_FROM_SPACE event in Google Chat. + * @param {Object} event the event object from Google Workspace Add On + */ +function onRemovedFromSpace(event) { + const space = event.chat.removedFromSpacePayload.space; + console.info(`Chat app removed from ${(space.name || "this chat")}`); +} + +// ---------------------- +// Util functions +// ---------------------- + +function createTextMessage(text) { return { text: text }; } + +function createCardMessage(card) { return { cardsV2: [{ card: card }]}; } + +function sendCreateMessageAction(message) { + return { hostAppDataAction: { chatDataAction: { createMessageAction: { message: message }}}}; +} diff --git a/solutions/ooo-assistant/Common.gs b/solutions/ooo-assistant/Common.gs new file mode 100644 index 000000000..f6499ad42 --- /dev/null +++ b/solutions/ooo-assistant/Common.gs @@ -0,0 +1,119 @@ +/** + * Copyright 2025 Google LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const UNIVERSAL_ACTION = "universal action"; + +// ---------------------- +// Homepage util functions +// ---------------------- + +/** + * Responds to homepage load request. + */ +function onHomepage() { + return help(); +} + +// ---------------------- +// Action util functions +// ---------------------- + +// Help action: Show add-on details. +function help(featureName = UNIVERSAL_ACTION) { + return { + header: addOnCardHeader(), + sections: [{ widgets: [{ + decoratedText: { text: "Hi! 👋 Feel free to use the following " + featureName + "s:", wrapText: true }}, { + decoratedText: { text: "⛔ Block day out: I will block out your calendar for today.", wrapText: true }}, { + decoratedText: { text: "â†Šī¸ Set auto reply: I will set an OOO auto reply in your Gmail.", wrapText: true } + }]}] + }; +} + +// Block day out action: Adds an all-day event to the user's Google Calendar. +function blockDayOut() { + blockOutCalendar(); + return createActionResponseCard('Your calendar is now blocked out for today.') +} + +// Creates an OOO event in the user's Calendar. +function blockOutCalendar() { + function getDateAndHours(hour, minutes) { + const date = new Date(); + date.setHours(hour); + date.setMinutes(minutes); + date.setSeconds(0); + date.setMilliseconds(0); + return date.toISOString(); + } + + const event = { + start: { dateTime: getDateAndHours(9, 0) }, + end: { dateTime: getDateAndHours(17, 0) }, + eventType: 'outOfOffice', + summary: 'OOO', + outOfOfficeProperties: { + autoDeclineMode: 'declineOnlyNewConflictingInvitations', + declineMessage: 'Declined because OOO.', + } + } + Calendar.Events.insert(event, 'primary'); +} + +// Set auto reply action: Set OOO auto reply in the user's Gmail . +function setAutoReply() { + turnOnAutoResponder(); + return createActionResponseCard('The out of office auto reply has been turned on.') +} + +// Turns on the user's vacation response for today in Gmail. +function turnOnAutoResponder() { + const ONE_DAY_MILLIS = 24 * 60 * 60 * 1000; + const currentTime = (new Date()).getTime(); + Gmail.Users.Settings.updateVacation({ + enableAutoReply: true, + responseSubject: 'I am OOO today', + responseBodyHtml: 'I am OOO today.

Created by OOO Assistant add-on!', + restrictToContacts: true, + restrictToDomain: true, + startTime: currentTime, + endTime: currentTime + ONE_DAY_MILLIS + }, 'me'); +} + +// ---------------------- +// Card util functions +// ---------------------- + +function addOnCardHeader() { + return { + title: "OOO Assistant", + subtitle: "Helping manage your OOO", + imageUrl: "https://goo.gle/3SfMkjb", + }; +} + +// Create an action response card +function createActionResponseCard(text) { + return { + header: addOnCardHeader(), + sections: [{ widgets: [{ decoratedText: { + startIcon: { iconUrl: "https://fonts.gstatic.com/s/i/short-term/web/system/1x/task_alt_gm_grey_48dp.png" }, + text: text, + wrapText: true + }}]}] + }; +} diff --git a/solutions/ooo-assistant/README.md b/solutions/ooo-assistant/README.md new file mode 100644 index 000000000..c7a524099 --- /dev/null +++ b/solutions/ooo-assistant/README.md @@ -0,0 +1,7 @@ +# Build a Google Workspace add-on extending all UIs + +The add-on extends the following Google Workspace UIs: Chat, Calendar, Gmail, Drive, Docs, Sheets, and Slides. + +It relies on app commands in Chat, and homepage and universal actions in the others. + +It's featured in [this YT video](https://www.youtube.com/watch?v=pDthZ2xssDc). diff --git a/solutions/ooo-assistant/appsscript.json b/solutions/ooo-assistant/appsscript.json new file mode 100644 index 000000000..a95705ef3 --- /dev/null +++ b/solutions/ooo-assistant/appsscript.json @@ -0,0 +1,40 @@ +{ + "timeZone": "America/New_York", + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8", + "dependencies": { + "enabledAdvancedServices": [{ + "userSymbol": "Gmail", + "version": "v1", + "serviceId": "gmail" + }, + { + "userSymbol": "Calendar", + "version": "v3", + "serviceId": "calendar" + }] + }, + "addOns": { + "common": { + "name": "OOO Assistant", + "logoUrl": "https://goo.gle/3SfMkjb", + "homepageTrigger": { + "runFunction": "onHomepage" + }, + "universalActions": [{ + "label": "Block day out", + "runFunction": "blockDayOut" + }, { + "label": "Set auto reply", + "runFunction": "setAutoReply" + }] + }, + "chat": {}, + "calendar": {}, + "gmail": {}, + "drive": {}, + "docs": {}, + "sheets": {}, + "slides": {} + } +} diff --git a/solutions/chat-bots/schedule-meetings/.clasp.json b/solutions/schedule-meetings/.clasp.json similarity index 100% rename from solutions/chat-bots/schedule-meetings/.clasp.json rename to solutions/schedule-meetings/.clasp.json diff --git a/solutions/chat-bots/schedule-meetings/Code.js b/solutions/schedule-meetings/Code.js similarity index 87% rename from solutions/chat-bots/schedule-meetings/Code.js rename to solutions/schedule-meetings/Code.js index 42b7f3f45..bec500809 100644 --- a/solutions/chat-bots/schedule-meetings/Code.js +++ b/solutions/schedule-meetings/Code.js @@ -1,5 +1,5 @@ // To learn how to use this script, refer to the documentation: -// https://developers.google.com/apps-script/samples/chat-bots/schedule-meetings +// https://developers.google.com/apps-script/samples/chat-apps/schedule-meetings /* Copyright 2022 Google LLC @@ -18,7 +18,7 @@ limitations under the License. */ // Application constants -const BOTNAME = 'Chat Meeting Scheduler'; +const APPNAME = 'Chat Meeting Scheduler'; const SLASHCOMMAND = { HELP: 1, // /help DIALOG: 2, // /schedule_Meeting @@ -26,16 +26,16 @@ const SLASHCOMMAND = { /** * Responds to an ADDED_TO_SPACE event in Google Chat. - * Called when the bot is added to a space. The bot can either be directly added to the space - * or added by a @mention. If the bot is added by a @mention, the event object includes a message property. - * Returns a Message object, which is usually a welcome message informing users about the bot. + * Called when the Chat app is added to a space. The Chat app can either be directly added to the space + * or added by a @mention. If the Chat app is added by a @mention, the event object includes a message property. + * Returns a Message object, which is usually a welcome message informing users about the Chat app. * * @param {Object} event The event object from Google Chat */ function onAddToSpace(event) { let message = ''; - // Personalizes the message depending on how the bot is called. + // Personalizes the message depending on how the Chat app is called. if (event.space.singleUserBotDm) { message = `Hi ${event.user.displayName}!`; } else { @@ -53,8 +53,8 @@ function onAddToSpace(event) { /** * Responds to a MESSAGE event triggered in Chat. - * Called when the bot is already in the space and the user invokes it via @mention or / command. - * Returns a message object containing the bot's response. For this bot, the response is either the + * Called when the Chat app is already in the space and the user invokes it via @mention or / command. + * Returns a message object containing the Chat app's response. For this Chat app, the response is either the * help text or the dialog to schedule a meeting. * * @param {object} event The event object from Google Chat @@ -176,15 +176,15 @@ function onCardClick(event) { } /** - * Responds with help text about this chat bot. + * Responds with help text about this Chat app. * @return {string} The help text as seen below */ function getHelpTextResponse_() { - const help = `*${BOTNAME}* lets you quickly create meetings from Google Chat. Here\'s a list of all its commands: + const help = `*${APPNAME}* lets you quickly create meetings from Google Chat. Here\'s a list of all its commands: \`/schedule_Meeting\` Opens a dialog with editable, preset parameters to create a meeting event \`/help\` Displays this help message - Learn more about creating Google Chat bots at https://developers.google.com/chat.` + Learn more about creating Google Chat apps at https://developers.google.com/chat.` return { 'text': help } } diff --git a/solutions/chat-bots/schedule-meetings/Dialog.js b/solutions/schedule-meetings/Dialog.js similarity index 100% rename from solutions/chat-bots/schedule-meetings/Dialog.js rename to solutions/schedule-meetings/Dialog.js diff --git a/solutions/chat-bots/schedule-meetings/README.md b/solutions/schedule-meetings/README.md similarity index 100% rename from solutions/chat-bots/schedule-meetings/README.md rename to solutions/schedule-meetings/README.md diff --git a/solutions/chat-bots/schedule-meetings/Utilities.js b/solutions/schedule-meetings/Utilities.js similarity index 100% rename from solutions/chat-bots/schedule-meetings/Utilities.js rename to solutions/schedule-meetings/Utilities.js diff --git a/solutions/chat-bots/schedule-meetings/appsscript.json b/solutions/schedule-meetings/appsscript.json similarity index 96% rename from solutions/chat-bots/schedule-meetings/appsscript.json rename to solutions/schedule-meetings/appsscript.json index 40869f567..113372528 100644 --- a/solutions/chat-bots/schedule-meetings/appsscript.json +++ b/solutions/schedule-meetings/appsscript.json @@ -3,6 +3,6 @@ "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "chat": { - "addToSpaceFallbackMessage": "Thank you for adding this Chat Bot!" + "addToSpaceFallbackMessage": "Thank you for adding this Chat App!" } } \ No newline at end of file