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

1. 简介

Google Workspace 插件是与 Gmail、文档、表格和幻灯片等 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 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

该插件需要获得用户权限才能运行其数据并对其执行操作。配置项目的同意屏幕即可启用此功能。在本 Codelab 中,您需要先将同意屏幕配置为内部应用,这意味着它不可公开分发。

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

创建容器

创建一个名为 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:[email protected] \
    --role=roles/run.admin
gcloud iam service-accounts add-iam-policy-binding \
    [email protected] \
    --member=serviceAccount:[email protected] \
    --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 身份验证库添加到项目中:

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 键来添加一些任务,然后点击复选框删除这些任务。

支持 Tasks 的插件

如果您愿意,可以跳至此 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 和管理 > 管理资源
  1. 在项目列表中,选择您的项目,然后点击删除
  2. 在对话框中输入项目 ID,然后点击关停以删除项目。

了解详情