使用 Node.js 为 Google Chat 构建交互式投票应用

1. 简介

Google Chat 应用可将您的服务和资源直接引入 Google Chat,让用户无需离开对话即可获取信息并快速采取行动。

在此 Codelab 中,您将学习如何使用 Node.js 和 Cloud Functions 构建并部署投票应用。

应用发布了一项投票,询问聊天室成员应用是否很棒,并通过互动式卡片消息收集投票。

学习内容

  • 使用 Cloud Shell
  • 部署到 Cloud Functions
  • 使用斜杠命令和对话框获取用户输入内容
  • 创建互动式卡片

2. 设置和要求

创建一个 Google Cloud 项目,然后启用 Chat 应用将使用的 API 和服务

前提条件

开发 Google Chat 应用需要拥有可访问 Google Chat 的 Google Workspace 账号。如果您还没有 Google Workspace 账号,请先创建一个账号并登录,然后再继续学习本 Codelab。

自定进度的环境设置

  1. 打开 Google Cloud 控制台,然后创建一个项目

    “选择项目”菜单“新建项目”按钮项目 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,这是一个在 Google Cloud 中运行的命令行环境。

激活 Cloud Shell

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

    菜单栏中的 Cloud Shell 图标

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

    Cloud Shell 欢迎消息

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

    Cloud Shell 终端

    这个虚拟机已加载了您需要的所有开发工具。它提供了一个持久的 5GB 主目录,并且在 Google Cloud 中运行,大大增强了网络性能和身份验证。您在此 Codelab 中执行的所有操作都可以使用浏览器或 Chromebook 完成。连接到 Cloud Shell 后,您应该会看到自己已通过身份验证,并且相关项目已设置为您的项目 ID。
  2. 在 Cloud Shell 中运行以下命令以确认您已通过身份验证:
    gcloud auth list
    
    如果系统提示您授权 Cloud Shell 进行 GCP API 调用,请点击授权

    命令输出
    Credentialed Accounts
    ACTIVE  ACCOUNT
    *       <my_account>@<my_domain.com>
    
    如果您的账号未默认选中,请运行以下命令:
    $ gcloud config set account <ACCOUNT>
    
  1. 确认您已选择正确的项目。在 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 编辑器。Cloud Shell 中还提供 Vim 和 Emacs 等热门编辑器。

3. 启用 Cloud Functions、Cloud Build 和 Google Chat API

在 Cloud Shell 中,启用以下 API 和服务:

gcloud services enable \
  cloudfunctions \
  cloudbuild.googleapis.com \
  chat.googleapis.com

此操作可能需要一点时间才能完成。

完成后,系统会显示类似如下内容的成功消息:

Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.

4. 创建初始聊天应用

初始化项目

首先,您将创建和部署一个简单的“Hello World”应用。Chat 应用属于 Web 服务,用于响应 https 请求以及使用 JSON 载荷进行响应。对于此应用,您将使用 Node.js 和 Cloud Functions

Cloud Shell 中,创建一个名为 poll-app 的新目录并前往此目录:

mkdir ~/poll-app
cd ~/poll-app

此 Codelab 的所有剩余工作成果以及您要创建的文件都位于此目录中。

初始化 Node.js 项目:

npm init

NPM 会询问有关项目配置的几个问题,例如名称和版本。对于每个问题,请按 ENTER 接受默认值。默认入口点是一个名为 index.js 的文件,我们将在下一步中创建该文件。

创建聊天应用后端

现在开始创建应用。创建一个名为 index.js 的文件,其中包含以下内容:

/**
 * App entry point.
 */
exports.app = async (req, res) => {
  if (!(req.method === 'POST' && req.body)) {
      res.status(400).send('')
  }
  const event = req.body;
  let reply = {};
  if (event.type === 'MESSAGE') {
    reply = {
        text: `Hello ${event.user.displayName}`
    };
  }
  res.json(reply)
}

该应用没有太多功能,但没关系。您稍后将添加更多功能。

部署应用

如需部署“Hello world”应用,您需要部署 Cloud Functions 函数,在 Google Cloud 控制台中配置 Chat 应用,并向该应用发送测试消息以验证部署。

部署 Cloud Functions 函数

如需部署“Hello World”应用的 Cloud Functions 函数,请输入以下命令:

gcloud functions deploy app --trigger-http --security-level=secure-always --allow-unauthenticated --runtime nodejs14

完成后,输出应如下所示:

availableMemoryMb: 256
buildId: 993b2ca9-2719-40af-86e4-42c8e4563a4b
buildName: projects/595241540133/locations/us-central1/builds/993b2ca9-2719-40af-86e4-42c8e4563a4b
entryPoint: app
httpsTrigger:
  securityLevel: SECURE_ALWAYS
  url: https://us-central1-poll-app-codelab.cloudfunctions.net/app
ingressSettings: ALLOW_ALL
labels:
  deployment-tool: cli-gcloud
name: projects/poll-app-codelab/locations/us-central1/functions/app
runtime: nodejs14
serviceAccountEmail: poll-app-codelab@appspot.gserviceaccount.com
sourceUploadUrl: https://storage.googleapis.com/gcf-upload-us-central1-66a01777-67f0-46d7-a941-079c24414822/94057943-2b7c-4b4c-9a21-bb3acffc84c6.zip
status: ACTIVE
timeout: 60s
updateTime: '2021-09-17T19:30:33.694Z'
versionId: '1'

请注意 httpsTrigger.url 属性中已部署函数的网址。您将在下一步中用到此网址。

配置应用

如需配置此应用,请转到 Cloud 控制台中的 Chat 配置页面

  1. 取消选中将此 Chat 扩展应用作为 Workspace 插件构建,然后点击停用进行确认。
  2. 应用名称中,输入“PollCodelab”。
  3. 头像网址中,输入 https://raw.githubusercontent.com/google/material-design-icons/master/png/social/poll/materialicons/24dp/2x/baseline_poll_black_24dp.png
  4. 说明中,输入“Codelab 的投票应用”。
  5. 功能下,选择接收一对一消息加入聊天室和群组对话
  6. 连接设置下,选择 HTTP 端点网址,然后粘贴 Cloud Functions 函数的网址(上一部分中的 httpsTrigger.url 属性)。
  7. 权限下方,选择您网域中的特定人员和群组,然后输入您的电子邮件地址。
  8. 点击保存

该应用现在可以发送消息了。

测试应用

在继续操作之前,请在 Google Chat 中将应用添加到聊天室,以检查其是否正常运行。

  1. 转到 Google Chat
  2. 在 Chat 旁边,点击 + > 查找应用
  3. 在搜索中输入“PollCodelab”。
  4. 点击 Chat
  5. 如需向应用发送消息,请输入“Hello”,然后按 Enter 键。

应用应以简短的问候消息进行响应。

现在,我们已经拥有了一个基本的框架,可以将其转变为更实用的应用!

5. 构建投票功能

应用运作方式的简要概览

该应用包含两个主要部分:

  1. 一条斜杠命令,显示用于配置投票的对话框。
  2. 用于投票和查看结果的互动式卡片。

应用还需要一些状态来存储投票配置和结果。这可以通过 Firestore 或任何数据库完成,也可以将状态存储在应用消息本身中。由于此应用旨在快速进行团队非正式投票,因此将状态存储在应用消息中非常适合此使用情形。

应用的数据模型(以 Typescript 表示)如下所示:

interface Poll {
  /* Question/topic of poll */
  topic: string;
  /** User that submitted the poll */
  author: {
    /** Unique resource name of user */
    name: string;
    /** Display name */
    displayName: string;
  };
  /** Available choices to present to users */
  choices: string[];
  /** Map of user ids to the index of their selected choice */
  votes: { [key: string]: number };
}

除了主题或问题及选项列表之外,状态还会包含作者的 ID 和姓名,以及记录的投票。为防止用户多次投票,投票会以用户 ID 与所选索引对应的形式存储。

当然,有许多不同的方法,但此方法为在聊天室中进行快速投票提供了一个很好的起点。

实现投票配置命令

如需允许用户发起和配置投票,请设置一个可打开对话框斜杠命令。此过程分为多个步骤:

  1. 注册用于启动投票的斜杠命令。
  2. 创建用于设置投票的对话框。
  3. 让应用识别并处理斜杠命令。
  4. 创建有助于在投票中进行投票的互动式卡片。
  5. 实现可让应用开展投票活动的代码。
  6. 重新部署 Cloud Functions 函数。

注册斜杠命令

要注册斜杠命令,请返回控制台中的 Chat 配置页面(API 和服务 > 信息中心 > Hangouts Chat API > 配置)。

  1. Slash 命令下,点击添加新的 Slash 命令
  2. 名称中,输入“/poll”
  3. Command id 中,输入“1”
  4. 说明中,输入“发起投票”。
  5. 选择打开对话框
  6. 点击完成
  7. 点击保存

应用现在可以识别 /poll 命令,并会打开一个对话框。接下来,我们来配置对话框。

以对话框形式创建配置表单

此斜杠命令会打开一个对话框,用于配置投票主题和可能的选项。创建一个名为 config-form.js 且包含以下内容的新文件:

/** Upper bounds on number of choices to present. */
const MAX_NUM_OF_OPTIONS = 5;

/**
 * Build widget with instructions on how to use form.
 *
 * @returns {object} card widget
 */
function helpText() {
  return {
    textParagraph: {
      text: 'Enter the poll topic and up to 5 choices in the poll. Blank options will be omitted.',
    },
  };
}

/**
 * Build the text input for a choice.
 *
 * @param {number} index - Index to identify the choice
 * @param {string|undefined} value - Initial value to render (optional)
 * @returns {object} card widget
 */
function optionInput(index, value) {
  return {
    textInput: {
      label: `Option ${index + 1}`,
      type: 'SINGLE_LINE',
      name: `option${index}`,
      value: value || '',
    },
  };
}

/**
 * Build the text input for the poll topic.
 *
 * @param {string|undefined} topic - Initial value to render (optional)
 * @returns {object} card widget
 */
function topicInput(topic) {
  return {
    textInput: {
      label: 'Topic',
      type: 'MULTIPLE_LINE',
      name: 'topic',
      value: topic || '',
    },
  };
}

/**
 * Build the buttons/actions for the form.
 *
 * @returns {object} card widget
 */
function buttons() {
  return {
    buttonList: {
      buttons: [
        {
          text: 'Submit',
          onClick: {
            action: {
              function: 'start_poll',
            },
          },
        },
      ],
    },
  };
}

/**
 * Build the configuration form.
 *
 * @param {object} options - Initial state to render with form
 * @param {string|undefined} options.topic - Topic of poll (optional)
 * @param {string[]|undefined} options.choices - Text of choices to display to users (optional)
 * @returns {object} card
 */
function buildConfigurationForm(options) {
  const widgets = [];
  widgets.push(helpText());
  widgets.push(topicInput(options.topic));
  for (let i = 0; i < MAX_NUM_OF_OPTIONS; ++i) {
    const choice = options?.choices?.[i];
    widgets.push(optionInput(i, choice));
  }
  widgets.push(buttons());

  // Assemble the card
  return {
    sections: [
      {
        widgets,
      },
    ],
  };
}

exports.MAX_NUM_OF_OPTIONS = MAX_NUM_OF_OPTIONS;
exports.buildConfigurationForm = buildConfigurationForm;

此代码会生成对话框表单,让用户设置投票。它还会导出一个常量,表示一个问题可以具有的最大选项数。最好将构建界面标记隔离到无状态函数中,并将所有状态作为参数传入。它有助于重复使用,并且稍后此卡片将在不同上下文中呈现。

此实现还会将卡片分解为更小的单元或组件。虽然不是硬性要求,但该方法是一种最佳实践,因为它在构建复杂界面时往往更易读且更易于维护。

如需查看其构建的完整 JSON 示例,请在 Card Builder 工具中查看。

处理斜杠命令

当斜杠命令发送到应用时,会显示为 MESSAGE 事件。更新 index.js 以通过 MESSAGE 事件检查斜杠命令是否存在,并使用对话框进行响应。将 index.js 替换为以下代码:

const { buildConfigurationForm, MAX_NUM_OF_OPTIONS } = require('./config-form');

/**
 * App entry point.
 */
exports.app = async (req, res) => {
  if (!(req.method === 'POST' && req.body)) {
      res.status(400).send('')
  }
  const event = req.body;
  let reply = {};
  // Dispatch slash and action events
  if (event.type === 'MESSAGE') {
    const message = event.message;
    if (message.slashCommand?.commandId === '1') {
      reply = showConfigurationForm(event);
    }
  } else if (event.type === 'CARD_CLICKED') {
    if (event.action?.actionMethodName === 'start_poll') {
      reply = await startPoll(event);
    }
  }
  res.json(reply);
}

/**
 * Handles the slash command to display the config form.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function showConfigurationForm(event) {
  // Seed the topic with any text after the slash command
  const topic = event.message?.argumentText?.trim();
  const dialog = buildConfigurationForm({
    topic,
    choices: [],
  });
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        dialog: {
          body: dialog,
        },
      },
    },
  };
}

/**
 * Handle the custom start_poll action.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function startPoll(event) {
  // Not fully implemented yet -- just close the dialog
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        actionStatus: {
          statusCode: 'OK',
          userFacingMessage: 'Poll started.',
        },
      },
    },
  }
}

现在,当调用 /poll 命令时,应用会显示一个对话框。通过从 Cloud Shell 中重新部署 Cloud Functions 函数来测试交互情况。

gcloud functions deploy app --trigger-http --security-level=secure-always

Cloud Functions 函数部署完毕后,使用 /poll 命令向应用发送消息,以测试斜杠命令和对话框。对话框会发送一个包含自定义操作 start_pollCARD_CLICKED 事件。系统会在更新后的入口点(调用 startPoll 方法的入口点)处理该事件。目前,startPoll 方法已桩化,仅用于关闭对话框。在下一部分中,您将实现投票功能并将所有部分连接在一起。

实现投票卡片

如需实现应用的投票部分,请首先定义一张交互式卡片,为用户提供一个投票界面。

实现投票界面

创建一个名为 vote-card.js 的文件,其中包含以下内容:

/**
 * Creates a small progress bar to show percent of votes for an option. Since
 * width is limited, the percentage is scaled to 20 steps (5% increments).
 *
 * @param {number} voteCount - Number of votes for this option
 * @param {number} totalVotes - Total votes cast in the poll
 * @returns {string} Text snippet with bar and vote totals
 */
function progressBarText(voteCount, totalVotes) {
  if (voteCount === 0 || totalVotes === 0) {
    return '';
  }

  // For progress bar, calculate share of votes and scale it
  const percentage = (voteCount * 100) / totalVotes;
  const progress = Math.round((percentage / 100) * 20);
  return '▀'.repeat(progress);
}

/**
 * Builds a line in the card for a single choice, including
 * the current totals and voting action.
 *
 * @param {number} index - Index to identify the choice
 * @param {string|undefined} value - Text of the choice
 * @param {number} voteCount - Current number of votes cast for this item
 * @param {number} totalVotes - Total votes cast in poll
 * @param {string} state - Serialized state to send in events
 * @returns {object} card widget
 */
function choice(index, text, voteCount, totalVotes, state) {
  const progressBar = progressBarText(voteCount, totalVotes);
  return {
    keyValue: {
      bottomLabel: `${progressBar} ${voteCount}`,
      content: text,
      button: {
        textButton: {
          text: 'vote',
          onClick: {
            action: {
              actionMethodName: 'vote',
              parameters: [
                {
                  key: 'state',
                  value: state,
                },
                {
                  key: 'index',
                  value: index.toString(10),
                },
              ],
            },
          },
        },
      },
    },
  };
}

/**
 * Builds the card header including the question and author details.
 *
 * @param {string} topic - Topic of the poll
 * @param {string} author - Display name of user that created the poll
 * @returns {object} card widget
 */
function header(topic, author) {
  return {
    title: topic,
    subtitle: `Posted by ${author}`,
    imageUrl:
      'https://raw.githubusercontent.com/google/material-design-icons/master/png/social/poll/materialicons/24dp/2x/baseline_poll_black_24dp.png',
    imageStyle: 'AVATAR',
  };
}

/**
 * Builds the configuration form.
 *
 * @param {object} poll - Current state of poll
 * @param {object} poll.author - User that submitted the poll
 * @param {string} poll.topic - Topic of poll
 * @param {string[]} poll.choices - Text of choices to display to users
 * @param {object} poll.votes - Map of cast votes keyed by user ids
 * @returns {object} card
 */
function buildVoteCard(poll) {
  const widgets = [];
  const state = JSON.stringify(poll);
  const totalVotes = Object.keys(poll.votes).length;

  for (let i = 0; i < poll.choices.length; ++i) {
    // Count votes for this choice
    const votes = Object.values(poll.votes).reduce((sum, vote) => {
      if (vote === i) {
        return sum + 1;
      }
      return sum;
    }, 0);
    widgets.push(choice(i, poll.choices[i], votes, totalVotes, state));
  }

  return {
    header: header(poll.topic, poll.author.displayName),
    sections: [
      {
        widgets,
      },
    ],
  };
}

exports.buildVoteCard = buildVoteCard;

实现方式类似于对话框采用的方法,但交互式卡片的标记与对话框略有不同。与之前一样,您可以在卡片生成器工具中查看生成的 JSON 示例。

实现投票操作

投票卡片包含每个选项的按钮。选项的索引和投票的序列化状态会附加到该按钮。应用会接收包含操作 voteCARD_CLICKED,以及作为参数附加到按钮的所有数据。

使用以下代码更新 index.js

const { buildConfigurationForm, MAX_NUM_OF_OPTIONS } = require('./config-form');
const { buildVoteCard } = require('./vote-card');

/**
 * App entry point.
 */
exports.app = async (req, res) => {
  if (!(req.method === 'POST' && req.body)) {
      res.status(400).send('')
  }
  const event = req.body;
  let reply = {};
  // Dispatch slash and action events
  if (event.type === 'MESSAGE') {
    const message = event.message;
    if (message.slashCommand?.commandId === '1') {
      reply = showConfigurationForm(event);
    }
  } else if (event.type === 'CARD_CLICKED') {
    if (event.action?.actionMethodName === 'start_poll') {
      reply = await startPoll(event);
    } else if (event.action?.actionMethodName === 'vote') {
        reply = recordVote(event);
    }
  }
  res.json(reply);
}

/**
 * Handles the slash command to display the config form.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function showConfigurationForm(event) {
  // Seed the topic with any text after the slash command
  const topic = event.message?.argumentText?.trim();
  const dialog = buildConfigurationForm({
    topic,
    choices: [],
  });
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        dialog: {
          body: dialog,
        },
      },
    },
  };
}

/**
 * Handle the custom start_poll action.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function startPoll(event) {
  // Not fully implemented yet -- just close the dialog
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        actionStatus: {
          statusCode: 'OK',
          userFacingMessage: 'Poll started.',
        },
      },
    },
  }
}

/**
 * Handle the custom vote action. Updates the state to record
 * the user's vote then rerenders the card.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function recordVote(event) {
  const parameters = event.common?.parameters;

  const choice = parseInt(parameters['index']);
  const userId = event.user.name;
  const state = JSON.parse(parameters['state']);

  // Add or update the user's selected option
  state.votes[userId] = choice;

  const card = buildVoteCard(state);
  return {
    thread: event.message.thread,
    actionResponse: {
      type: 'UPDATE_MESSAGE',
    },
    cards: [card],
  }
}

recordVote 方法会解析存储的状态并根据用户的投票更新该状态,然后重新呈现卡片。每次更新时,投票结果都会进行序列化处理并与卡片一起存储。

连接片段

应用即将完成。实现斜杠命令和投票功能后,剩下的唯一任务就是完成 startPoll 方法。

不过,这里有个问题。

提交投票配置后,应用需要执行两项操作:

  1. 关闭对话框。
  2. 向聊天室发布包含投票卡的新的消息。

很遗憾,直接回复 HTTP 请求只能执行一次,并且必须是第一次。如需发布投票卡片,应用必须使用 Chat API 异步创建新消息。

添加客户端库

运行以下命令,以更新应用的依赖项,使其包含适用于 Node.js 的 Google API 客户端。

npm install --save googleapis

开始投票

index.js 更新到以下最终版本:

const { buildConfigurationForm, MAX_NUM_OF_OPTIONS } = require('./config-form');
const { buildVoteCard } = require('./vote-card');
const {google} = require('googleapis');

/**
 * App entry point.
 */
exports.app = async (req, res) => {
  if (!(req.method === 'POST' && req.body)) {
      res.status(400).send('')
  }
  const event = req.body;
  let reply = {};
  // Dispatch slash and action events
  if (event.type === 'MESSAGE') {
    const message = event.message;
    if (message.slashCommand?.commandId === '1') {
      reply = showConfigurationForm(event);
    }
  } else if (event.type === 'CARD_CLICKED') {
    if (event.action?.actionMethodName === 'start_poll') {
      reply = await startPoll(event);
    } else if (event.action?.actionMethodName === 'vote') {
        reply = recordVote(event);
    }
  }
  res.json(reply);
}

/**
 * Handles the slash command to display the config form.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function showConfigurationForm(event) {
  // Seed the topic with any text after the slash command
  const topic = event.message?.argumentText?.trim();
  const dialog = buildConfigurationForm({
    topic,
    choices: [],
  });
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        dialog: {
          body: dialog,
        },
      },
    },
  };
}

/**
 * Handle the custom start_poll action.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
async function startPoll(event) {
  // Get the form values
  const formValues = event.common?.formInputs;
  const topic = formValues?.['topic']?.stringInputs.value[0]?.trim();
  const choices = [];
  for (let i = 0; i < MAX_NUM_OF_OPTIONS; ++i) {
    const choice = formValues?.[`option${i}`]?.stringInputs.value[0]?.trim();
    if (choice) {
      choices.push(choice);
    }
  }

  if (!topic || choices.length === 0) {
    // Incomplete form submitted, rerender
    const dialog = buildConfigurationForm({
      topic,
      choices,
    });
    return {
      actionResponse: {
        type: 'DIALOG',
        dialogAction: {
          dialog: {
            body: dialog,
          },
        },
      },
    };
  }

  // Valid configuration, build the voting card to display
  // in the space
  const pollCard = buildVoteCard({
    topic: topic,
    author: event.user,
    choices: choices,
    votes: {},
  });
  const message = {
    cards: [pollCard],
  };
  const request = {
    parent: event.space.name,
    requestBody: message,
  };
  // Use default credentials (service account)
  const credentials = new google.auth.GoogleAuth({
    scopes: ['https://www.googleapis.com/auth/chat.bot'],
  });
  const chatApi = google.chat({
    version: 'v1',
    auth: credentials,
  });
  await chatApi.spaces.messages.create(request);

  // Close dialog
  return {
    actionResponse: {
      type: 'DIALOG',
      dialogAction: {
        actionStatus: {
          statusCode: 'OK',
          userFacingMessage: 'Poll started.',
        },
      },
    },
  };
}

/**
 * Handle the custom vote action. Updates the state to record
 * the user's vote then rerenders the card.
 *
 * @param {object} event - chat event
 * @returns {object} Response to send back to Chat
 */
function recordVote(event) {
  const parameters = event.common?.parameters;

  const choice = parseInt(parameters['index']);
  const userId = event.user.name;
  const state = JSON.parse(parameters['state']);

  // Add or update the user's selected option
  state.votes[userId] = choice;

  const card = buildVoteCard(state);
  return {
    thread: event.message.thread,
    actionResponse: {
      type: 'UPDATE_MESSAGE',
    },
    cards: [card],
  }
}

重新部署函数:

gcloud functions deploy app --trigger-http --security-level=secure-always

现在,您应该能够完全试验该应用。请尝试调用 /poll 命令提供一个问题和几个选项。提交后,系统会显示投票卡片。

投下您的票,看看会发生什么。

当然,您自己投票不一定体现应用的实用性,不妨邀请一些好友或同事来试一试!

6. 恭喜

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

其他活动

如果您想更深入地探索 Chat 平台和此应用,可以自行尝试以下操作:

  • 当您 @ 提及应用时,会发生什么情况?请尝试更新应用以改善行为。
  • 序列化卡片中的投票状态对于小型聊天室是可以的,但有限制。不妨尝试切换到更好的方案。
  • 如果作者想修改投票或停止接受新投票,该怎么办?您会如何实现这些功能?
  • 应用端点尚未受到保护。尝试添加一些验证,以确保请求来自 Google Chat。

以上只是改进应用的几种不同方式。尽情发挥您的想象力,享受其中的乐趣吧!

清理

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

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

了解详情

如需详细了解如何开发 Chat 应用,请参阅:

如需详细了解如何在 Google Cloud 控制台中进行开发,请参阅: