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

1. 簡介

Google Workspace 外掛程式是與 Gmail、文件、試算表和簡報等 Google Workspace 應用程式整合的自訂應用程式。能讓開發人員建立自訂使用者介面,並直接與 Google Workspace 整合。外掛程式能減少環境切換,提升使用者工作效率。

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

課程內容

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

2. 設定和需求

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

自修環境設定

  1. 開啟 Cloud 控制台,然後建立新專案。(如果您還沒有 Gmail 或 Google Workspace 帳戶,請先建立帳戶)。

選取專案選單

「New Project」按鈕

專案 ID

提醒您,專案 ID 是所有 Google Cloud 專案的專屬名稱 (已經有人使用上述名稱,很抱歉對您不符!)。稍後在本程式碼研究室中會稱為 PROJECT_ID

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

執行這個程式碼研究室並不會產生任何費用,如果有的話。請務必依照「清理」中指示部分,其中說明如何關閉資源,這樣就不會產生本教學課程結束後產生的費用。Google Cloud 的新使用者符合 $300 美元免費試用計畫的資格。

Google Cloud Shell

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

啟用 Cloud Shell

  1. 在 Cloud 控制台中,按一下「啟用 Cloud Shell」圖示 Cloud Shell 圖示

選單列中的 Cloud Shell 圖示

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

Cloud Shell 歡迎訊息

佈建並連線至 Cloud Shell 只需幾分鐘的時間。連線之後,您會看到 Cloud Shell 終端機:

Cloud Shell 終端機

這個虛擬機器搭載您需要的所有開發工具。提供永久的 5 GB 主目錄,而且在 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. 填寫表單:
    • 在「應用程式名稱」中輸入「待辦事項外掛程式」。
    • 在「使用者支援電子郵件」中,輸入您的個人電子郵件地址。
    • 在「開發人員聯絡資訊」下方,輸入您的個人電子郵件地址。
  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. 存取使用者身分

外掛程式通常是用來處理使用者或其機構私有的資訊。在本程式碼研究室中,外掛程式只能顯示目前使用者的工作。使用者身分會透過需要解碼的識別權杖傳送至外掛程式。

將範圍新增至部署作業描述元

根據預設,系統不會傳送使用者身分。那是使用者資料,外掛程式需要存取權限。如要取得該權限,請更新 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

更新外掛程式伺服器

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

剖析識別權杖

請先將 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 以實作「待辦事項」一開始先匯入資料儲存庫程式庫並建立用戶端:

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 中重新載入外掛程式,使用者介面也會出現。請花點時間探索這個外掛程式。如要新增多項工作,請在輸入字詞中輸入文字,按下鍵盤上的 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

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

開啟外掛程式。這個外掛程式會在外掛程式的底部顯示「Authorize File Access」按鈕。按一下按鈕,然後授權存取檔案。

取得授權後,請在編輯器中新增工作。工作會顯示標籤,指出文件已附加成功。按一下連結,在新分頁中開啟文件。當然,開啟您目前開啟的文件有點傻。如果您要最佳化使用者介面,過濾掉「目前」文件的連結,不妨把這些額外的功勞!

8. 恭喜

恭喜!您已成功透過 Cloud Run 建構及部署 Google Workpace 外掛程式。本程式碼研究室涵蓋了建構外掛程式的許多核心概念,但還有許多值得探索的地方。歡迎參考下列資源,並別忘了清除專案,以免產生額外費用。

清除所用資源

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

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

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

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

瞭解詳情

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