使用 Node.js 建構 Google Chat 互動式投票應用程式

1. 簡介

Google Chat 擴充應用程式可將服務和資源直接帶入 Google Chat,讓使用者不必離開對話,就能取得資訊並快速採取行動。

在本程式碼研究室中,您將瞭解如何使用 Node.js 和 Cloud Functions 建構及部署投票應用程式。

應用程式發布投票活動,詢問聊天室成員應用程式是否很棒,並透過互動式資訊卡訊息收集票數。

課程內容

  • 使用 Cloud Shell
  • 部署至 Cloud Functions
  • 使用斜線指令和對話方塊取得使用者輸入內容
  • 建立互動式資訊卡

2. 設定和需求

建立 Google Cloud 專案,然後啟用 Chat 應用程式會使用的 API 和服務

必要條件

如要開發 Google Chat 應用程式,您必須擁有可存取 Google Chat 的 Google Workspace 帳戶。如果您還沒有 Google Workspace 帳戶,請先建立並登入,再繼續進行本程式碼研究室。

自行設定環境

  1. 開啟 Google Cloud 控制台並建立專案

    「選取專案」選單「新專案」按鈕專案 ID

    請記下專案 ID,這是所有 Google Cloud 專案的專屬名稱 (上方的名稱已遭占用,因此不適用於您,抱歉!)。本程式碼研究室稍後會將其稱為 PROJECT_ID
  1. 接著,如要使用 Google Cloud 資源,請在 Cloud Console 中啟用計費功能

完成本程式碼研究室的練習不會產生任何費用,或只會產生少量費用。請務必按照程式碼研究室最後「清除」一節中的操作說明,關閉資源,以免產生本教學課程以外的費用。Google Cloud 新使用者可參加價值$300 美元的免費試用計畫。

Google Cloud Shell

雖然您可以透過筆電遠端操作 Google Cloud,但在本程式碼研究室中,我們將使用 Google Cloud Shell,這是 Google Cloud 中執行的指令列環境。

啟用 Cloud Shell

  1. 在 Cloud Shell 中,按一下「啟用 Cloud Shell」圖示 Cloud Shell 圖示

    選單列中的 Cloud Shell 圖示

    首次開啟 Cloud Shell 時,系統會顯示說明性歡迎訊息。如果看到歡迎訊息,請按一下「繼續」。系統不會再顯示歡迎訊息。歡迎訊息如下:

    Cloud Shell 歡迎訊息

    佈建並連線至 Cloud Shell 的作業很快就能完成。連線後,您會看到 Cloud Shell 終端機:

    Cloud Shell 終端機

    這部虛擬機器搭載您所需的所有開發工具。提供永久的 5 GB 主目錄,而且在 Google Cloud 中運作,可大幅提升網路效能和驗證功能。您可以使用瀏覽器或 Chromebook 完成本程式碼研究室的所有作業。連線至 Cloud Shell 後,您應會發現自己通過驗證,且專案已設為您的專案 ID。
  2. 在 Cloud Shell 中執行下列指令,確認您已通過驗證:
    gcloud auth list
    
    如果系統提示您授權 Cloud Shell 發出 GCP API 呼叫,請按一下「Authorize」(授權)

    指令輸出內容
    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].
    

完成本程式碼研究室時,您會使用指令列作業並編輯檔案。如要編輯檔案,可以點選 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. 建立初始 Chat 應用程式

初始化專案

首先,請建立及部署簡單的「Hello world」應用程式。即時通訊應用程式是網路服務,會回應 https 要求,並以 JSON 酬載回覆。這個應用程式會使用 Node.js 和 Cloud Functions

Cloud Shell 中,建立名為 poll-app 的新目錄,然後前往該目錄:

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

您在本程式碼研究室中完成的所有其餘工作,以及建立的檔案,都會位於這個目錄中。

初始化 Node.js 專案:

npm init

NPM 會詢問專案設定的相關問題,例如名稱和版本。針對每個問題,按下 ENTER 接受預設值。預設進入點是名為 index.js 的檔案,我們接下來會建立這個檔案。

建立 Chat 應用程式後端

現在開始建立應用程式。建立名為 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 函式、在 Google Cloud 控制台中設定 Chat 應用程式,然後傳送測試訊息給應用程式,確認部署作業。

部署 Cloud 函式

如要部署「Hello world」應用程式的 Cloud 函式,請輸入下列指令:

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 configuration」(即時通訊設定) 頁面

  1. 取消勾選「將這個 Chat 擴充應用程式建構為 Workspace 外掛程式」,然後按一下「停用」確認。
  2. 在「應用程式名稱」中輸入「PollCodelab」。
  3. 在「Avatar URL」中輸入 https://raw.githubusercontent.com/google/material-design-icons/master/png/social/poll/materialicons/24dp/2x/baseline_poll_black_24dp.png
  4. 在「說明」中,輸入「Poll app for codelab」。
  5. 在「功能」下方,選取「接收一對一訊息」和「加入聊天室和群組對話」
  6. 在「連線設定」下方,選取「HTTP 端點網址」,然後貼上 Cloud Function 的網址 (上一節中的 httpsTrigger.url 屬性)。
  7. 在「權限」下方,選取「僅限您網域中的特定使用者和群組」,然後輸入您的電子郵件地址。
  8. 按一下「儲存」

應用程式現在可以傳送訊息。

測試應用程式

繼續操作前,請先將應用程式新增至 Google Chat 中的群組,確認應用程式是否正常運作。

  1. 前往 Google Chat
  2. 依序按一下「即時通訊」旁邊的「+」>「尋找應用程式」
  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 函式。

註冊斜線指令

如要註冊斜線指令,請返回控制台的「Chat configuration」(Chat 設定) 頁面 (依序點選「APIs & Services」(API 和服務) >「Dashboard」(資訊主頁) >「Hangouts Chat API」 >「Configuration」(設定))。

  1. 在「斜線指令」下方,按一下「新增斜線指令」
  2. 在「名稱」中輸入「/poll」
  3. 在「Command id」(指令 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;

這段程式碼會產生對話方塊表單,讓使用者設定投票。此外,也會匯出問題可擁有的選項數量上限常數。建議您將 UI 標記建構作業隔離到無狀態函式中,並將所有狀態做為參數傳遞。方便重複使用,而且稍後會在不同情境中顯示這張資訊卡。

這項實作也會將資訊卡分解為較小的單元或元件。雖然不是必要做法,但建議您採用這項技術,因為在建構複雜介面時,這項技術通常更容易閱讀及維護。

如要查看完整 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 Function,測試互動。

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

部署 Cloud 函式後,請使用 /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 範例。

實作投票動作

投票資訊卡會顯示每個選項的按鈕。該選項的索引和輪詢的序列化狀態會附加至按鈕。應用程式會收到 CARD_CLICKED,其中包含動作 vote,以及附加至按鈕的任何資料 (做為參數)。

使用下列項目更新 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 應用程式。雖然本程式碼研究室涵蓋了許多建構應用程式的核心概念,但還有許多內容值得探索。請參閱下列資源,並記得清除專案,以免產生額外費用。

其他活動

如要進一步瞭解 Chat 平台和這個應用程式,可以嘗試下列做法:

  • 如果 @ 提及應用程式,會發生什麼事?請嘗試更新應用程式,改善這類行為。
  • 在資訊卡中序列化投票狀態適用於小型空間,但有其限制。請改用其他選項。
  • 如果作者想編輯投票或停止接受新票數,該怎麼做?你會如何實作這些功能?
  • 應用程式端點尚未受到保護。請嘗試新增一些驗證,確保要求來自 Google Chat。

以上只是幾種改善應用程式的方法,歡迎盡情發揮想像力!

清除所用資源

如要避免系統向您的 Google Cloud Platform 帳戶收取您在本教學課程中所用資源的相關費用:

  • 前往 Cloud Console 中的「管理資源」頁面。依序點選左上角的「選單」選單圖示「IAM 與管理」>「管理資源」
  1. 在專案清單中選取專案,然後按一下「刪除」
  2. 在對話方塊中輸入專案 ID,然後按一下「Shut down」(關閉) 刪除專案。

瞭解詳情

如要進一步瞭解如何開發 Chat 應用程式,請參閱:

如要進一步瞭解如何在 Google Cloud 控制台中開發,請參閱: