1. 簡介
Google Workspace 外掛程式是與 Gmail、文件、試算表和簡報等 Google Workspace 應用程式整合的自訂應用程式。能讓開發人員建立自訂使用者介面,並直接與 Google Workspace 整合。外掛程式能減少環境切換,提升使用者工作效率。
在本程式碼研究室中,您將瞭解如何使用 Node.js、Cloud Run 和 Datastore 建構及部署簡單的工作清單外掛程式。
課程內容
- 使用 Cloud Shell
- 部署至 Cloud Run
- 建立及部署外掛程式部署作業描述元
- 使用資訊卡架構建立外掛程式 UI
- 回應使用者互動
- 在外掛程式中運用使用者情境
2. 設定和需求
按照設定操作說明建立 Google Cloud 專案,並啟用外掛程式要使用的 API 和服務。
自修環境設定
提醒您,專案 ID 是所有 Google Cloud 專案的專屬名稱 (已經有人使用上述名稱,很抱歉對您不符!)。稍後在本程式碼研究室中會稱為 PROJECT_ID
。
- 接著,如要使用 Google Cloud 資源,請在 Cloud 控制台中啟用計費功能。
執行這個程式碼研究室並不會產生任何費用,如果有的話。請務必依照「清理」中指示部分,其中說明如何關閉資源,這樣就不會產生本教學課程結束後產生的費用。Google Cloud 的新使用者符合 $300 美元免費試用計畫的資格。
Google Cloud Shell
雖然 Google Cloud 可以從筆電遠端操作,但在本程式碼研究室中,我們會使用 Google Cloud Shell,這是在 Cloud 中執行的指令列環境。
啟用 Cloud Shell
- 在 Cloud 控制台中,按一下「啟用 Cloud Shell」圖示 。
在您初次開啟 Cloud Shell 時,系統會顯示明確的歡迎訊息。如果看到歡迎訊息,請按一下「繼續」。系統不會再顯示歡迎訊息。歡迎訊息如下:
佈建並連線至 Cloud Shell 只需幾分鐘的時間。連線之後,您會看到 Cloud Shell 終端機:
這個虛擬機器搭載您需要的所有開發工具。提供永久的 5 GB 主目錄,而且在 Google Cloud 中運作,大幅提高網路效能和驗證能力。本程式碼研究室中的所有工作都可以透過瀏覽器或 Chromebook 完成。
連線至 Cloud Shell 後,您應會發現自己通過驗證,且專案已設為您的專案 ID。
- 在 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
建立 OAuth 同意畫面
外掛程式需要使用者授權,才能執行和對資料執行動作。設定專案的同意畫面即可啟用這項功能。在程式碼研究室中,您需要將同意畫面設為內部應用程式 (也就是不開放公開發布)。
- 在新的分頁或視窗中開啟 Google Cloud 控制台。
- 在「Google Cloud 控制台」旁邊按一下向下箭頭 ,然後選取專案。
- 按一下左上角的「選單」圖示 。
- 按一下 [API 與]服務 >憑證。專案的憑證頁面隨即顯示。
- 按一下 [OAuth consent screen] (OAuth 同意畫面)。「OAuth 同意畫面」畫面上會顯示提示訊息。
- 在「使用者類型」下方選取「內部」。如果使用 @gmail.com 帳戶,請選取「外部」。
- 點選「建立」。「編輯應用程式註冊資訊」出現。
- 填寫表單:
- 在「應用程式名稱」中輸入「待辦事項外掛程式」。
- 在「使用者支援電子郵件」中,輸入您的個人電子郵件地址。
- 在「開發人員聯絡資訊」下方,輸入您的個人電子郵件地址。
- 點選「儲存並繼續」。畫面隨即顯示「範圍」表單。
- 在「範圍」表單中,按一下「儲存並繼續」。系統隨即會顯示摘要。
- 按一下「返回資訊主頁」。
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 部署,應用程式必須容器化。
建立容器
建立一個名為 Dockerfile
的 Dockerfile,其中包含以下項目:
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
,並將 openid
和 email
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 帳戶收取您在本教學課程中所用資源的相關費用:
- 在專案清單中,選取您的專案,然後按一下「Delete」(刪除)。
- 在對話方塊中輸入專案 ID,然後按一下「Shut down」(關閉) 即可刪除專案。
瞭解詳情
- Google Workspace 外掛程式總覽
- 在 Marketplace 中尋找現有的應用程式和外掛程式