1. 简介
Google Workspace 插件是与 Gmail、文档、表格和幻灯片等 Google Workspace 应用集成的自定义应用。借助这些工具,开发者可以创建直接集成到 Google Workspace 中的自定义界面。借助插件,您可以减少上下文切换,从而更高效地工作。
在此 Codelab 中,您将学习如何使用 Node.js、Cloud Run 和 Datastore 构建和部署简单的任务列表插件。
学习内容
- 使用 Cloud Shell
- 部署到 Cloud Run
- 创建和部署插件部署描述符
- 使用卡片框架创建插件界面
- 响应用户互动
- 在插件中利用用户情境
2. 设置和要求
按照设置说明创建 Google Cloud 项目,并启用该插件将使用的 API 和服务。
自定进度的环境设置
请记住项目 ID,它在所有 Google Cloud 项目中都是唯一的名称(上述名称已被占用,您无法使用,抱歉!)。它稍后将在此 Codelab 中被称为 PROJECT_ID
。
- 接下来,若要使用 Google Cloud 资源,请在 Cloud 控制台中启用结算功能。
运行此 Codelab 应该不会产生太多的费用(如果有费用的话)。请务必按照“清理”部分中的说明操作部分,其中会指导您如何关停资源,以免产生超出本教程范围的结算费用。Google Cloud 的新用户符合参与 $300 USD 免费试用计划的条件。
Google Cloud Shell
虽然 Google Cloud 可以通过笔记本电脑远程操作,但在此 Codelab 中,我们将使用 Google Cloud Shell,这是一个在云端运行的命令行环境。
激活 Cloud Shell
- 在 Cloud Console 中,点击激活 Cloud Shell
。
您首次打开 Cloud Shell 时,会看到一条描述性的欢迎消息。如果您看到欢迎消息,请点击继续。欢迎消息不再显示。欢迎辞如下:
预配和连接到 Cloud Shell 应该只需要片刻时间。连接后,您会看到 Cloud Shell 终端:
这个虚拟机装有您需要的所有开发工具。它提供了一个持久的 5GB 主目录,并且在 Google Cloud 中运行,大大增强了网络性能和身份验证。您在此 Codelab 中的所有工作都可以使用浏览器或 Chromebook 完成。
在连接到 Cloud Shell 后,您应该会看到自己已通过身份验证,并且相关项目已设置为您的项目 ID。
- 在 Cloud Shell 中运行以下命令以确认您已通过身份验证:
gcloud auth list
如果系统提示您授权 Cloud Shell 进行 GCP API 调用,请点击授权。
命令输出
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].
此 Codelab 混合使用命令行操作和文件编辑。如需编辑文件,您可以点击 Cloud Shell 工具栏右侧的打开编辑器按钮,使用 Cloud Shell 中内置的代码编辑器。您还可以在 Cloud Shell 中找到热门编辑器,例如 vim 和 emacs。
3. 启用 Cloud Run API、Datastore API 和 Add-on 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 权限请求页面
该插件需要获得用户权限才能运行其数据并对其执行操作。配置项目的同意屏幕即可启用此功能。在本 Codelab 中,您需要先将同意屏幕配置为内部应用,这意味着它不可公开分发。
- 在新的标签页或窗口中打开 Google Cloud 控制台。
- 在“Google Cloud 控制台”旁边点击向下箭头
,然后选择您的项目。
- 点击左上角的“菜单”图标
。
- 点击 API 和服务 >凭据。系统会显示您的项目的凭据页面。
- 点击 OAuth 同意屏幕。“OAuth 同意屏幕”屏幕。
- 在“用户类型”下选择内部。如果您使用的是 @gmail.com 账号,请选择外部。
- 点击创建。“修改应用注册”页面。
- 填写表单:
- 在应用名称中,输入“Todo Add-on”。
- 在用户支持电子邮件地址中,输入您的个人电子邮件地址。
- 在开发者联系信息下,输入您的个人电子邮件地址。
- 点击保存并继续。此时将显示“Scopes”(范围)表单。
- 在“Scopes”(范围)表单中,点击 Save and Continue(保存并继续)。系统会显示摘要。
- 点击返回信息中心。
4. 创建初始插件
初始化项目
首先,您需要创建简单的“Hello World”插件并进行部署插件是 Web 服务,可响应 https 请求并使用 JSON 载荷(描述界面和要采取的操作)进行响应。在此插件中,您将使用 Node.js 和 Express 框架。
如需创建此模板项目,请使用 Cloud Shell 创建一个名为 todo-add-on
的新目录并导航到该目录:
mkdir ~/todo-add-on cd ~/todo-add-on
您将在此目录中完成此 Codelab 的所有工作。
初始化 Node.js 项目:
npm init
NPM 会询问一些有关项目配置的问题,例如名称和版本。对于每个问题,请按 ENTER
接受默认值。默认入口点是一个名为 index.js
的文件,我们接下来将创建该文件。
接下来,安装 Express Web 框架:
npm install --save express express-async-handler
创建插件后端
现在,可以开始创建应用了。
创建一个名为 index.js
的文件。如需创建文件,您可以点击 Cloud Shell 窗口工具栏上的打开编辑器按钮,使用 Cloud Shell Editor。或者,您也可以使用 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
在此 Codelab 中,您将在添加新功能时多次构建和部署该插件。不要运行单独的命令来构建容器、将其推送到容器注册库并部署到 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. 访问用户身份
通常,许多用户使用插件来处理他们或其单位的私密信息。在此 Codelab 中,该插件应仅显示当前用户的任务。通过需要解码的身份令牌将用户身份发送到插件。
向部署描述符添加范围
默认情况下,系统不会发送用户身份。这些数据是用户数据,该插件需要获得访问权限。若要获得该权限,请更新 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');
然后,添加一个辅助方法以解析 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. 实现任务列表
此 Codelab 的初始数据模型非常简单:Task
实体的列表,每个实体都有任务描述性文本的属性和时间戳。
创建数据存储区索引
在此 Codelab 的前面部分,我们已为该项目启用 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);
}
实现界面的呈现
大部分更改都是针对插件界面。之前,界面返回的所有卡片都是静态的,不会根据可用数据而发生变化。在这里,需要根据用户当前的任务列表动态构建卡片。
此 Codelab 的界面包含一个文本输入以及一系列任务,其中带有复选框可将任务标记为完成。其中每个对象还具有一个 onChangeAction
属性,当用户添加或删除任务时,该属性会导致回调到插件服务器。在每种情况下,都需要使用更新后的任务列表重新渲染界面。为处理此问题,我们将引入一种用于构建卡片界面的新方法。
继续修改 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 读取数据和向其中写入数据以及构建界面的辅助方法,接下来让我们在应用路由中将它们连接在一起。替换现有路由并添加两项:一个用于添加任务,另一个用于删除任务。
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 键来添加一些任务,然后点击复选框删除这些任务。
如果您愿意,可以跳至此 Codelab 中的最后一步,并清理您的项目。或者,如果你想继续详细了解插件,还可以再完成一个步骤。
7. (可选)添加上下文
插件最强大的功能之一是情境感知。在获得用户许可的情况下,插件可以访问 Google Workspace 上下文,例如用户正在查看的电子邮件、日历活动和文档。插件还可以执行插入内容等操作。在此 Codelab 中,您将为 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。不过,默认情况下,编辑器中不会共享上下文。与其他用户数据一样,用户必须授权该插件访问这些数据。为防止过度共享信息,首选方法是按文件请求和授予权限。
更新界面
在 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 创建一个新文档。如果要创建新文档,请务必输入一些文本或为文件命名。
打开插件。该插件底部会显示授权文件访问权限按钮。点击该按钮,然后授予文件访问权限。
获得授权后,请在编辑器中添加任务。该任务包含一个标签,用于表明已附加文档。点击链接即可在新标签页中打开该文档。当然,打开已经打开的文档有点傻。如果您想优化界面以过滤掉当前文档的链接,请考虑使用额外资源!
8. 恭喜
恭喜!您已成功使用 Cloud Run 构建和部署 Google Workpace 插件。虽然此 Codelab 介绍了构建插件的许多核心概念,但还有许多内容需要探索。请参阅以下资源,同时别忘了清理您的项目以免产生额外费用。
清理
如需从您的账号卸载该插件,请在 Cloud Shell 中运行以下命令:
gcloud workspace-add-ons deployments uninstall todo-add-on
为避免因本教程中使用的资源导致您的 Google Cloud Platform 账号产生费用,请执行以下操作:
- 在 Cloud Console 中,转到管理资源页面。 点击左上角的菜单图标
> IAM 和管理 > 管理资源。
- 在项目列表中,选择您的项目,然后点击删除。
- 在对话框中输入项目 ID,然后点击关停以删除项目。
了解详情
- Google Workspace 插件概览
- 在 Marketplace 中查找现有应用和插件