使用 Node.js 和 Cloud Run 构建 Google Workspace 插件

1. 简介

Google Workspace 加购项是与 Gmail、Google 文档、Google 表格和 Google 幻灯片等 Google Workspace 应用集成的自定义应用。借助它们,开发者可以创建直接集成到 Google Workspace 中的自定义界面。插件可帮助用户更高效地工作,减少上下文切换。

在此 Codelab 中,您将学习如何使用 Node.js、Cloud RunDatastore 构建和部署简单的任务列表插件。

学习内容

  • 使用 Cloud Shell
  • 部署到 Cloud Run
  • 创建并部署插件部署描述符
  • 使用卡片框架创建插件界面
  • 响应用户互动
  • 在插件中利用用户上下文

2. 设置和要求

按照设置说明创建 Google Cloud 云项目,并启用该插件将使用的 API 和服务。

自定进度的环境设置

  1. 打开 Cloud 控制台并创建一个新项目。(如果您还没有 Gmail 或 Google Workspace 账号,请创建一个。)

“选择项目”菜单

“新建项目”按钮

项目 ID

请记住项目 ID,它在所有 Google Cloud 项目中都是唯一的名称(上述名称已被占用,您无法使用,抱歉!)。它稍后将在此 Codelab 中被称为 PROJECT_ID

  1. 接下来,为了使用 Google Cloud 资源,请在 Cloud 控制台中启用结算功能

运行此 Codelab 应该不会产生太多的费用(如果有费用的话)。请务必按照本 Codelab 末尾的“清理”部分中的所有说明操作,该部分介绍了如何关停资源,以免产生超出本教程范围的结算费用。Google Cloud 的新用户符合参与 $300 USD 免费试用计划的条件。

Google Cloud Shell

虽然可以通过笔记本电脑对 Google Cloud 进行远程操作,但在此 Codelab 中,我们将使用 Google Cloud Shell,这是一个在云端运行的命令行环境。

激活 Cloud Shell

  1. 在 Cloud Console 中,点击激活 Cloud ShellCloud Shell 图标

菜单栏中的 Cloud Shell 图标

首次打开 Cloud Shell 时,系统会显示一条描述性欢迎消息。如果您看到欢迎信息,请点击继续。欢迎辞不会再次显示。以下是欢迎消息:

Cloud Shell 欢迎辞

预配和连接到 Cloud Shell 只需花几分钟时间。连接后,您会看到 Cloud Shell 终端:

Cloud Shell 终端

这个虚拟机已加载了您需要的所有开发工具。它提供了一个持久的 5GB 主目录,并且在 Google Cloud 中运行,大大增强了网络性能和身份验证。您在此 Codelab 中的所有工作都可以使用浏览器或 Chromebook 完成。

在连接到 Cloud Shell 后,您应该会看到自己已通过身份验证,并且相关项目已设置为您的项目 ID。

  1. 在 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、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

该插件需要获得用户许可才能运行并对用户数据执行操作。配置项目的权限请求页面以启用此功能。在本 Codelab 中,您将配置权限请求页面作为内部应用(即不用于公开分发),以便开始学习。

  1. 在新标签页或窗口中打开 Google Cloud 控制台
  2. 在“Google Cloud 控制台”旁边,点击下拉箭头 下拉箭头,然后选择您的项目。
  3. 点击左上角的“菜单”图标 “菜单”图标
  4. 依次点击 API 和服务 > 凭据。系统会显示项目的“凭据”页面。
  5. 点击 OAuth 同意屏幕。系统会显示“OAuth 权限请求页面”。
  6. 在“用户类型”下,选择内部。如果使用 @gmail.com 账号,请选择外部
  7. 点击创建。系统随即会显示“修改应用注册”页面。
  8. 填写表单:
    • 应用名称中,输入“Todo Add-on”。
    • 用户支持电子邮件地址中,输入您的个人电子邮件地址。
    • 开发者联系信息下方,输入您的个人电子邮件地址。
  9. 点击保存并继续。系统会显示“范围”表单。
  10. 在“范围”表单中,点击保存并继续。系统随即会显示摘要。
  11. 点击返回信息中心

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 窗口工具栏上的打开编辑器按钮即可。或者,您也可以使用 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

在此 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 并将 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 Auth 库添加到项目中:

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 以实现“待办事项”,首先导入 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);
}

实现界面呈现

大多数更改都与插件界面有关。之前,界面返回的所有卡片都是静态的,不会根据可用数据而变化。在此处,卡片需要根据用户的当前任务列表动态构建。

此 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 编辑器(Google 文档、表格和幻灯片)添加上下文支持,以便在编辑器中创建任务时附加当前文档。当任务显示时,点击该任务会在新标签页中打开文档,以便用户返回文档完成任务。

更新插件后端

更新 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

完成后,请打开现有 Google 文档或通过打开 doc.new 创建新文档,而不是打开 Gmail。如果创建新文档,请务必输入一些文字或为文件命名。

打开插件。该插件会在底部显示一个授权文件访问权限按钮。点击相应按钮,然后授权访问该文件。

获得授权后,在编辑器中添加任务。任务包含一个标签,用于指示文档已附加。点击链接后,系统会在新标签页中打开相应文档。当然,打开已打开的文档有点傻。如果您想优化界面以过滤掉当前文档的链接,请考虑额外学分!

8. 恭喜

恭喜!您已成功使用 Cloud Run 构建并部署了 Google Workspace 插件。虽然此 Codelab 涵盖了构建插件的许多核心概念,但还有更多内容值得探索。请参阅以下资源,并务必清理项目,以免产生额外费用。

清理

如需从您的账号中卸载该插件,请在 Cloud Shell 中运行以下命令:

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

为避免因本教程中使用的资源导致您的 Google Cloud Platform 账号产生费用,请执行以下操作:

  • 在 Cloud Console 中,转到管理资源页面。 点击左上角的菜单图标 “菜单”图标 > IAM 和管理 > 管理资源
  1. 在项目列表中,选择您的项目,然后点击删除
  2. 在对话框中输入项目 ID,然后点击关停以删除项目。

了解详情