使用 Node.js 和 Cloud Run 建構 Google Workspace 外掛程式

1. 簡介

Google Workspace 外掛程式是整合 Gmail、Google 文件、試算表和簡報等 Google Workspace 應用程式的自訂應用程式。開發人員可藉此建立直接整合至 Google Workspace 的自訂使用者介面。外掛程式可協助使用者提高工作效率,減少切換情境的次數。

在本程式碼研究室中,您將瞭解如何使用 Node.js、Cloud RunDatastore 建構及部署簡單的工作清單外掛程式。

課程內容

  • 使用 Cloud Shell
  • 部署至 Cloud Run
  • 建立及部署外掛程式部署描述元
  • 使用資訊卡架構建立外掛程式使用者介面
  • 回應使用者互動
  • 在外掛程式中運用使用者環境資訊

2. 設定和需求條件

按照設定操作說明建立 Google Cloud 專案,並啟用外掛程式會使用的 API 和服務。

自修實驗室環境設定

  1. 開啟 Cloud Console 並建立新專案。(如果沒有 Gmail 或 Google Workspace 帳戶,請建立帳戶)。

「選取專案」選單

「新專案」按鈕

專案 ID

請記住專案 ID,這是所有 Google Cloud 專案中不重複的名稱 (上述名稱已遭占用,因此不適用於您,抱歉!)。本程式碼研究室稍後會將其稱為 PROJECT_ID

  1. 接著,如要使用 Google Cloud 資源,請在 Cloud Console 中啟用計費功能

完成本程式碼研究室的費用應該不高,甚至完全免費。請務必按照程式碼研究室最後「清除」部分的指示操作,瞭解如何停用資源,避免在本教學課程結束後繼續產生帳單費用。Google Cloud 新使用者可參加價值$300 美元的免費試用計畫。

Google Cloud Shell

雖然您可以透過筆電遠端操作 Google Cloud,但在本程式碼研究室中,我們將使用 Google Cloud Shell,這是可在雲端執行的指令列環境。

啟用 Cloud Shell

  1. 在 Cloud 控制台,點選「啟用 Cloud Shell」 圖示 Cloud Shell 圖示

選單列中的 Cloud Shell 圖示

首次開啟 Cloud Shell 時,系統會顯示說明性質的歡迎訊息。如果看到歡迎訊息,請按一下「繼續」。系統不會再顯示歡迎訊息。歡迎訊息如下:

Cloud Shell 歡迎訊息

佈建並連至 Cloud Shell 預計只需要幾分鐘。連線後,您會看到 Cloud Shell 終端機:

Cloud Shell 終端機

這部虛擬機器搭載您需要的所有開發工具,並提供永久的 5GB 主目錄,而且可在 Google Cloud 運作,大幅提升網路效能並強化驗證功能。您可以使用瀏覽器或 Chromebook 完成本程式碼研究室的所有工作。

連線至 Cloud Shell 後,您應會發現自己通過驗證,且專案已設為您的專案 ID。

  1. 在 Cloud Shell 中執行下列指令,確認您已通過驗證:
gcloud auth list

如果系統提示您授權 Cloud Shell 發出 GCP API 呼叫,請按一下「Authorize」(授權)

指令輸出

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

如要設定使用中帳戶,請執行:

gcloud config set account <ACCOUNT>

如要確認您已選取正確的專案,請在 Cloud Shell 中執行下列指令:

gcloud config list project

指令輸出

[core]
project = <PROJECT_ID>

如未傳回正確的專案,請輸入下列指令手動設定專案:

gcloud config set project <PROJECT_ID>

指令輸出

Updated property [core/project].

本程式碼研究室會混合使用指令列作業和檔案編輯。如要編輯檔案,可以點選 Cloud Shell 工具列右側的「開啟編輯器」按鈕,使用 Cloud Shell 內建的程式碼編輯器。您也可以在 Cloud Shell 中使用 vim 和 emacs 等熱門編輯器。

3. 啟用 Cloud Run、Datastore 和外掛程式 API

啟用 Cloud API

在 Cloud Shell 中,為要使用的元件啟用 Cloud API:

gcloud services enable \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  cloudresourcemanager.googleapis.com \
  datastore.googleapis.com \
  gsuiteaddons.googleapis.com

這項作業可能需要一些時間才能完成。

完成後,系統會顯示類似以下的成功訊息:

Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.

建立資料儲存庫執行個體

接著,啟用 App Engine,並建立 Datastore 資料庫。啟用 App Engine 是使用 Datastore 的先決條件,但我們不會將 App Engine 用於其他用途。

gcloud app create --region=us-central
gcloud firestore databases create --type=datastore-mode --region=us-central

外掛程式需要使用者授權,才能執行並對資料採取行動。如要啟用這項功能,請設定專案的同意畫面。在本程式碼研究室中,您會將同意畫面設定為內部應用程式 (也就是不公開發布),以便開始使用。

  1. 在新分頁或視窗中開啟 Google Cloud 控制台
  2. 按一下「Google Cloud 控制台」旁的向下箭頭 下拉式箭頭,然後選取專案。
  3. 按一下左上角的「選單」圖示 選單圖示
  4. 依序按一下「API 和服務」>「憑證」。專案的憑證頁面隨即顯示。
  5. 按一下 [OAuth consent screen] (OAuth 同意畫面)。系統會顯示「OAuth 同意畫面」。
  6. 在「使用者類型」下方,選取「內部」。如果使用 @gmail.com 帳戶,請選取「外部」
  7. 按一下「建立」,「編輯應用程式註冊」頁面隨即顯示。
  8. 填寫表單:
    • 在「應用程式名稱」中輸入「Todo Add-on」。
    • 在「使用者支援電子郵件」部分,輸入您的個人電子郵件地址。
    • 在「開發人員聯絡資訊」下方,輸入您的個人電子郵件地址。
  9. 按一下「儲存並繼續」。系統會顯示「範圍」表單。
  10. 在「範圍」表單中,按一下「儲存並繼續」。系統會顯示摘要。
  11. 按一下「返回資訊主頁」

4. 建立初始外掛程式

初始化專案

首先,您要建立簡單的「Hello world」外掛程式並部署。外掛程式是網路服務,會回應 https 要求,並傳回 JSON 酬載,說明要採取的 UI 和動作。在本外掛程式中,您將使用 Node.js 和 Express 架構。

如要建立這個範本專案,請使用 Cloud Shell 建立名為 todo-add-on 的新目錄,然後前往該目錄:

mkdir ~/todo-add-on
cd ~/todo-add-on

您將在這個目錄中完成程式碼研究室的所有工作。

初始化 Node.js 專案:

npm init

NPM 會詢問專案設定的相關問題,例如名稱和版本。針對每個問題按下 ENTER,接受預設值。預設進入點是名為 index.js 的檔案,我們接下來會建立這個檔案。

接著,請安裝 Express 網路架構:

npm install --save express express-async-handler

建立外掛程式後端

現在開始建立應用程式。

建立名為 index.js 的檔案。如要建立檔案,請點選 Cloud Shell 視窗工具列中的「開啟編輯器」按鈕,使用 Cloud Shell 編輯器。或者,您也可以使用 vim 或 emacs,在 Cloud Shell 中編輯及管理檔案。

建立 index.js 檔案後,請加入下列內容:

const express = require('express');
const asyncHandler = require('express-async-handler');

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post("/", asyncHandler(async (req, res) => {
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello world!`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

伺服器除了顯示「Hello world」訊息外,不會執行其他動作,這沒問題。稍後會新增更多功能。

部署至 Cloud Run

如要在 Cloud Run 上部署應用程式,必須先將應用程式容器化。

建立容器

建立名為 DockerfileDockerfile,其中包含:

FROM node:12-slim

# Create and change to the app directory.
WORKDIR /usr/src/app

# Copy application dependency manifests to the container image.
# A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
# Copying this first prevents re-running npm install on every code change.
COPY package*.json ./

# Install production dependencies.
# If you add a package-lock.json, speed your build by switching to 'npm ci'.
# RUN npm ci --only=production
RUN npm install --only=production

# Copy local code to the container image.
COPY . ./

# Run the web service on container startup.
CMD [ "node", "index.js" ]

避免將不必要的檔案放入容器

為確保容器輕巧,請建立包含下列內容的 .dockerignore 檔案:

Dockerfile
.dockerignore
node_modules
npm-debug.log

啟用 Cloud Build

在本程式碼研究室中,您將在新增功能時多次建構及部署外掛程式。您不必分別執行指令來建構容器、將容器推送至容器登錄檔,以及將容器部署至 Cloud Build,而是使用 Cloud Build 協調程序。建立 cloudbuild.yaml 檔案,內含建構及部署應用程式的操作說明:

steps:
 # Build the container image
 - name: 'gcr.io/cloud-builders/docker'
   args: ['build', '-t', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME', '.']
 # Push the container image to Container Registry
 - name: 'gcr.io/cloud-builders/docker'
   args: ['push', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME']
 # Deploy container image to Cloud Run
 - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
   entrypoint: gcloud
   args:
   - 'run'
   - 'deploy'
   - '$_SERVICE_NAME'
   - '--image'
   - 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
   - '--region'
   - '$_REGION'
   - '--platform'
   - 'managed'
images:
 - 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
substitutions:
   _SERVICE_NAME: todo-add-on
   _REGION: us-central1

執行下列指令,授予 Cloud Build 部署應用程式的權限:

PROJECT_ID=$(gcloud config list --format='value(core.project)')
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
gcloud projects add-iam-policy-binding $PROJECT_ID \
    --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
    --role=roles/run.admin
gcloud iam service-accounts add-iam-policy-binding \
    $PROJECT_NUMBER-compute@developer.gserviceaccount.com \
    --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
    --role=roles/iam.serviceAccountUser

建構及部署外掛程式後端

如要啟動建構作業,請在 Cloud Shell 中執行下列指令:

gcloud builds submit

完整建構及部署作業可能需要幾分鐘才能完成,尤其是第一次。

建構完成後,請確認服務已部署並找出網址。執行下列指令:

gcloud run services list --platform managed

複製這個網址,下個步驟會用到,也就是告訴 Google Workspace 如何叫用外掛程式。

註冊外掛程式

伺服器已啟動並執行,接下來請描述外掛程式,讓 Google Workspace 知道如何顯示及叫用外掛程式。

建立部署描述元

建立 deployment.json 檔案,並加入以下內容。請務必使用已部署應用程式的網址,取代 URL 預留位置。

{
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.execute",
    "https://www.googleapis.com/auth/calendar.addons.execute"
  ],
  "addOns": {
    "common": {
      "name": "Todo Codelab",
      "logoUrl": "https://raw.githubusercontent.com/webdog/octicons-png/main/black/check.png",
      "homepageTrigger": {
        "runFunction": "URL"
      }
    },
    "gmail": {},
    "drive": {},
    "calendar": {},
    "docs": {},
    "sheets": {},
    "slides": {}
  }
}

執行下列指令,上傳部署作業描述元:

gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json

授權存取外掛程式後端

外掛程式架構也需要呼叫服務的權限。執行下列指令,更新 Cloud Run 的 IAM 政策,允許 Google Workspace 叫用外掛程式:

SERVICE_ACCOUNT_EMAIL=$(gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)")
gcloud run services add-iam-policy-binding todo-add-on --platform managed --region us-central1 --role roles/run.invoker --member "serviceAccount:$SERVICE_ACCOUNT_EMAIL"

安裝外掛程式進行測試

如要在帳戶的開發模式中安裝外掛程式,請在 Cloud Shell 中執行下列指令:

gcloud workspace-add-ons deployments install todo-add-on

在新分頁或視窗中開啟 (Gmail)[https://mail.google.com/]。在右側找出有勾號圖示的擴充功能。

已安裝的外掛程式圖示

如要開啟外掛程式,請按一下勾號圖示。系統會顯示授權外掛程式的提示。

授權提示

按一下「授權存取權」,然後按照彈出式視窗中的授權流程指示操作。完成後,外掛程式會自動重新載入,並顯示「Hello world!」訊息。

恭喜!您現在已部署並安裝簡單的外掛程式。現在就將其變成工作清單應用程式吧!

5. 存取使用者身分識別資訊

許多使用者通常會使用外掛程式處理個人或機構的私人資訊。在本程式碼研究室中,外掛程式應只顯示目前使用者的工作。系統會透過需要解碼的身分權杖,將使用者身分傳送至外掛程式。

在部署描述元中新增範圍

系統預設不會傳送使用者 ID。這是使用者資料,外掛程式需要取得存取權限。如要取得這項權限,請更新 deployment.json,並將 openidemail OAuth 範圍新增至外掛程式所需的範圍清單。新增 OAuth 範圍後,外掛程式會在使用者下次使用時,提示他們授予存取權。

"oauthScopes": [
      "https://www.googleapis.com/auth/gmail.addons.execute",
      "https://www.googleapis.com/auth/calendar.addons.execute",
      "openid",
      "email"
],

接著,在 Cloud Shell 中執行下列指令,更新部署描述元:

gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json

更新外掛程式伺服器

雖然外掛程式已設定為要求使用者身分,但實作方式仍須更新。

剖析 ID 權杖

首先,請將 Google 驗證程式庫新增至專案:

npm install --save google-auth-library

然後編輯 index.js,要求使用 OAuth2Client

const { OAuth2Client } = require('google-auth-library');

然後新增 Helper 方法來剖析 ID 權杖:

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

顯示使用者身分

在新增所有工作清單功能之前,現在是設定檢查點的好時機。更新應用程式的路徑,列印使用者的電子郵件地址和專屬 ID,而非「Hello world」。

app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello ${user.email} ${user.sub}`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

完成這些變更後,產生的 index.js 檔案應如下所示:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello ${user.email} ${user.sub}`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

重新部署並測試

重新建構並重新部署外掛程式。在 Cloud Shell 中執行下列指令:

gcloud builds submit

重新部署伺服器後,開啟或重新載入 Gmail,然後再次開啟外掛程式。由於範圍已變更,外掛程式會要求重新授權。再次授權外掛程式,完成後外掛程式會顯示您的電子郵件地址和使用者 ID。

外掛程式現在知道使用者是誰,您可以開始新增工作清單功能。

6. 實作工作清單

本程式碼研究室的初始資料模型很簡單:Task 實體清單,每個實體都包含工作描述文字和時間戳記的屬性。

建立資料儲存庫索引

程式碼研究室稍早已為專案啟用 Datastore。不需要結構定義,但必須明確建立複合式查詢的索引。建立索引可能需要幾分鐘,因此請先執行這項操作。

建立名為 index.yaml 的檔案,並在當中加入下列內容:

indexes:
- kind: Task
  ancestor: yes
  properties:
  - name: created

然後更新 Datastore 索引:

gcloud datastore indexes create index.yaml

系統提示您繼續操作時,請按下鍵盤上的 Enter 鍵。系統會在背景建立索引。在此期間,請開始更新外掛程式程式碼,以實作「待辦事項」。

更新外掛程式後端

將 Datastore 程式庫安裝至專案:

npm install --save @google-cloud/datastore

讀取及寫入 Datastore

更新 index.js,實作「todos」,首先匯入 Datastore 程式庫並建立用戶端:

const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

新增從 Datastore 讀取及寫入工作的方法:

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

實作 UI 的算繪作業

大部分變更都與外掛程式 UI 有關。先前 UI 傳回的所有資訊卡都是靜態的,不會根據可用資料而變更。在這裡,系統需要根據使用者的目前工作清單,動態建構卡片。

本程式碼研究室的 UI 包含文字輸入欄位,以及附有核取方塊的工作清單,方便您將工作標示為完成。每個項目也都有 onChangeAction 屬性,會在使用者新增或刪除工作時,回呼至外掛程式伺服器。在上述任一情況下,UI 都需要使用更新後的工作清單重新算繪。為處理這項問題,我們將介紹建構資訊卡 UI 的新方法。

繼續編輯 index.js,並新增下列方法:

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    // Input for adding a new task
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        // Create text & checkbox for each task
        tasks.forEach(task => taskListSection.widgets.push({
            decoratedText: {
                text: task.text,
                wrapText: true,
                switchControl: {
                    controlType: 'CHECKBOX',
                    name: 'completedTasks',
                    value: task[datastore.KEY].id,
                    selected: false,
                    onChangeAction: {
                        function: `${baseUrl}/complete`,
                    }
                }
            }
        }));
    } else {
        // Placeholder for empty task list
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    }
    return card;
}

更新路線

現在有了讀取及寫入 Datastore 和建構 UI 的輔助方法,接下來要在應用程式路徑中將這些方法連結在一起。取代現有路徑,並新增兩條路徑:一條用於新增工作,另一條用於刪除工作。

app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date()
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

以下是最終的完整 index.js 檔案:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date()
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    // Input for adding a new task
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        // Create text & checkbox for each task
        tasks.forEach(task => taskListSection.widgets.push({
            decoratedText: {
                text: task.text,
                wrapText: true,
                switchControl: {
                    controlType: 'CHECKBOX',
                    name: 'completedTasks',
                    value: task[datastore.KEY].id,
                    selected: false,
                    onChangeAction: {
                        function: `${baseUrl}/complete`,
                    }
                }
            }
        }));
    } else {
        // Placeholder for empty task list
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    }
    return card;
}

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

重新部署並測試

如要重建及重新部署外掛程式,請啟動建構作業。在 Cloud Shell 執行下列指令:

gcloud builds submit

在 Gmail 中重新載入外掛程式,即可看到新版 UI。請花點時間探索外掛程式。在輸入欄位中輸入文字,然後按下鍵盤上的 Enter 鍵,即可新增幾項工作,接著按一下核取方塊即可刪除。

外掛程式與工作

您可以視需要直接跳到本程式碼研究室的最後一個步驟,然後清理專案。或者,如要進一步瞭解外掛程式,可以完成最後一個步驟。

7. (選用) 新增背景資訊

外掛程式最強大的功能之一就是情境感知。外掛程式可存取 Google Workspace 環境 (例如使用者正在查看的電子郵件、日曆活動和文件),但必須取得使用者授權。外掛程式也可以執行插入內容等動作。在本程式碼研究室中,您將為 Workspace 編輯器 (文件、試算表和簡報) 新增內容支援功能,以便在編輯器中建立工作時,將目前的文件附加至工作。工作顯示後,使用者只要點選工作,系統就會在新分頁中開啟文件,讓使用者返回文件完成工作。

更新外掛程式後端

更新 newTask 路線

首先,請更新 /newTask 路由,在工作 (如有) 中加入文件 ID:

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    // Get the current document if it is present
    const editorInfo = event.docs || event.sheets || event.slides;
    let document = null;
    if (editorInfo && editorInfo.id) {
        document = {
            id: editorInfo.id,
        }
    }
    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date(),
        document,
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

新建立的工作現在會包含目前的文件 ID。不過,編輯器中的內容預設不會共用。與其他使用者資料一樣,使用者必須授予外掛程式存取資料的權限。為避免過度分享資訊,建議您逐一要求及授予檔案權限。

更新 UI

index.js 中更新 buildCard,進行兩項變更。首先,我們會更新工作項目的顯示方式,加入文件連結 (如有)。第二種是如果外掛程式在編輯器中算繪,且尚未授予檔案存取權,則顯示選用的授權提示。

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        tasks.forEach(task => {
            const widget = {
                decoratedText: {
                    text: task.text,
                    wrapText: true,
                    switchControl: {
                        controlType: 'CHECKBOX',
                        name: 'completedTasks',
                        value: task[datastore.KEY].id,
                        selected: false,
                        onChangeAction: {
                            function: `${baseUrl}/complete`,
                        }
                    }
                }
            };
            // Make item clickable and open attached doc if present
            if (task.document) {
                widget.decoratedText.bottomLabel = 'Click to open  document.';
                const id = task.document.id;
                const url = `https://drive.google.com/open?id=${id}`
                widget.decoratedText.onClick = {
                    openLink: {
                        openAs: 'FULL_SIZE',
                        onClose: 'NOTHING',
                        url: url,
                    }
                }
            }
            taskListSection.widgets.push(widget)
        });
    } else {
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    };

    // Display file authorization prompt if the host is an editor
    // and no doc ID present
    const event = req.body;
    const editorInfo = event.docs || event.sheets || event.slides;
    const showFileAuth = editorInfo && editorInfo.id === undefined;
    if (showFileAuth) {
        card.fixedFooter = {
            primaryButton: {
                text: 'Authorize file access',
                onClick: {
                    action: {
                        function: `${baseUrl}/authorizeFile`,
                    }
                }
            }
        }
    }
    return card;
}

實作檔案授權路徑

授權按鈕會為應用程式新增路徑,因此請實作該路徑。這個路徑會介紹新的概念:主機應用程式動作。這些是與外掛程式主機應用程式互動的特殊指示。在本例中,要求存取目前的編輯器檔案。

index.js 中,新增 /authorizeFile 路由:

app.post('/authorizeFile', asyncHandler(async (req, res) => {
    const responsePayload = {
        renderActions: {
            hostAppAction: {
                editorAction: {
                    requestFileScopeForActiveDocument: {}
                }
            },
        }
    };
    res.json(responsePayload);
}));

以下是最終的完整 index.js 檔案:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    // Get the current document if it is present
    const editorInfo = event.docs || event.sheets || event.slides;
    let document = null;
    if (editorInfo && editorInfo.id) {
        document = {
            id: editorInfo.id,
        }
    }
    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date(),
        document,
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/authorizeFile', asyncHandler(async (req, res) => {
    const responsePayload = {
        renderActions: {
            hostAppAction: {
                editorAction: {
                    requestFileScopeForActiveDocument: {}
                }
            },
        }
    };
    res.json(responsePayload);
}));

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        tasks.forEach(task => {
            const widget = {
                decoratedText: {
                    text: task.text,
                    wrapText: true,
                    switchControl: {
                        controlType: 'CHECKBOX',
                        name: 'completedTasks',
                        value: task[datastore.KEY].id,
                        selected: false,
                        onChangeAction: {
                            function: `${baseUrl}/complete`,
                        }
                    }
                }
            };
            // Make item clickable and open attached doc if present
            if (task.document) {
                widget.decoratedText.bottomLabel = 'Click to open  document.';
                const id = task.document.id;
                const url = `https://drive.google.com/open?id=${id}`
                widget.decoratedText.onClick = {
                    openLink: {
                        openAs: 'FULL_SIZE',
                        onClose: 'NOTHING',
                        url: url,
                    }
                }
            }
            taskListSection.widgets.push(widget)
        });
    } else {
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    };

    // Display file authorization prompt if the host is an editor
    // and no doc ID present
    const event = req.body;
    const editorInfo = event.docs || event.sheets || event.slides;
    const showFileAuth = editorInfo && editorInfo.id === undefined;
    if (showFileAuth) {
        card.fixedFooter = {
            primaryButton: {
                text: 'Authorize file access',
                onClick: {
                    action: {
                        function: `${baseUrl}/authorizeFile`,
                    }
                }
            }
        }
    }
    return card;
}

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

在部署描述元中新增範圍

重建伺服器前,請更新外掛程式部署描述元,加入 https://www.googleapis.com/auth/drive.file OAuth 範圍。更新 deployment.json,將 https://www.googleapis.com/auth/drive.file 新增至 OAuth 範圍清單:

"oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.execute",
    "https://www.googleapis.com/auth/calendar.addons.execute",
    "https://www.googleapis.com/auth/drive.file",
    "openid",
    "email"
]

執行下列 Cloud Shell 指令,上傳新版本:

gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json

重新部署並測試

最後,請重建伺服器。在 Cloud Shell 中執行下列指令:

gcloud builds submit

完成後,請開啟現有的 Google 文件,或開啟 doc.new 建立新文件,而非開啟 Gmail。如要建立新文件,請務必輸入一些文字或為檔案命名。

開啟外掛程式。外掛程式底部會顯示「授權檔案存取權」按鈕。按一下按鈕,然後授權存取檔案。

授權後,即可在編輯器中新增工作。工作會顯示標籤,指出文件已附加。點選連結後,系統會在新的分頁中開啟文件。當然,開啟已開啟的文件有點愚蠢。如要最佳化使用者介面,篩除目前文件的連結,請將此視為額外學分!

8. 恭喜

恭喜!您已成功使用 Cloud Run 建構及部署 Google Workspace 外掛程式。雖然本程式碼研究室涵蓋了許多建構外掛程式的核心概念,但還有許多內容值得探索。請參閱下列資源,並記得清除專案,以免產生額外費用。

清除所用資源

如要從帳戶解除安裝外掛程式,請在 Cloud Shell 中執行下列指令:

gcloud workspace-add-ons deployments uninstall todo-add-on

如要避免系統向您的 Google Cloud Platform 帳戶收取您在本教學課程中所用資源的相關費用:

  • 前往 Cloud Console 中的「管理資源」頁面。依序點選左上角的「選單」選單圖示「IAM 與管理」>「管理資源」
  1. 在專案清單中選取專案,然後按一下「刪除」
  2. 在對話方塊中輸入專案 ID,然後按一下「Shut down」(關閉) 刪除專案。

瞭解詳情

  • Google Workspace 外掛程式總覽
  • Marketplace 中尋找現有應用程式和外掛程式