1. Introduction
Google Chat apps bring your services and resources right into Google Chat, letting users get information and take quick action without leaving the conversation.
In this codelab, you'll learn how to build and deploy a poll app using Node.js and Cloud Functions.
What you'll learn
- Use the Cloud Shell
- Deploy to Cloud Functions
- Get user input with slash commands and dialogs
- Create interactive cards
2. Setup and requirements
Create a Google Cloud project, then enable the APIs and services that the Chat app will use
Prerequisites
Developing a Google Chat app requires a Google Workspace account with access to Google Chat. If you don't already have a Google Workspace account, create one and sign in before proceeding with this codelab.
Self-paced environment setup
- Open the Google Cloud Console and create a project.
Remember the project ID, a unique name across all Google Cloud projects (the name above has already been taken and will not work for you, sorry!). It will be referred to later in this codelab asPROJECT_ID
.
- Next, in order to use Google Cloud resources, enable billing in Cloud Console.
Running through this codelab shouldn't cost much, if anything at all. Be sure to follow any instructions in the "Clean up" section at the end of the codelab which advises you how to shut down resources so you don't incur billing beyond this tutorial. New users of Google Cloud are eligible for the $300USD Free Trial program.
Google Cloud Shell
While Google Cloud can be operated remotely from your laptop, in this codelab we will use Google Cloud Shell, a command line environment running in Google Cloud.
Activate Cloud Shell
- From the Cloud Console, click Activate Cloud Shell
.
The first time you open Cloud Shell, you're presented with a descriptive welcome message. If you see the welcome message, click Continue. The welcome message doesn't appear again. Here's the welcome message:
It should only take a few moments to provision and connect to Cloud Shell. After connecting, you see the Cloud Shell Terminal:
This virtual machine is loaded with all the development tools you need. It offers a persistent 5GB home directory and runs in Google Cloud, greatly enhancing network performance and authentication. All of your work in this codelab can be done with a browser or your Chromebook.Once connected to Cloud Shell, you should see that you are already authenticated and that the project is already set to your project ID. - Run the following command in Cloud Shell to confirm that you are authenticated:
If you are prompted to authorize Cloud Shell to make a GCP API call, click Authorize.gcloud auth list
Command output If your account is not selected by default, run:Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
$ gcloud config set account <ACCOUNT>
- Confirm you have selected the correct project. In Cloud Shell, run:
Command outputgcloud config list project
If the correct project is not returned, you can set it with this command:[core] project = <PROJECT_ID>
Command outputgcloud config set project <PROJECT_ID>
Updated property [core/project].
As you complete this codelab, you'll use command line operations and edit files. For file editing, you can work with Cloud Shell's built-in code editor, Cloud Shell Editor, by clicking Open Editor on the right hand side of the Cloud Shell toolbar. Popular editors such as Vim and Emacs are also available in Cloud Shell.
3. Enable Cloud Functions, Cloud Build, and Google Chat APIs
From Cloud Shell, enable these APIs and services:
gcloud services enable \ cloudfunctions \ cloudbuild.googleapis.com \ chat.googleapis.com
This operation may take a few moments to complete.
Once completed, a success message similar to this one appears:
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
4. Create the initial Chat app
Initialize the project
To begin, you'll create and deploy a simple "Hello world" app. Chat apps are web services that respond to https requests and respond with a JSON payload. For this app, you'll use Node.js and Cloud Functions.
In Cloud Shell, create a new directory named poll-app
and navigate to it:
mkdir ~/poll-app cd ~/poll-app
All the remaining work for the codelab and the files you'll create will be in this directory.
Initialize the Node.js project:
npm init
NPM asks several questions about the project configuration, such as name and version. For each question, press ENTER
to accept the default values. The default entry point is a file named index.js
, which we'll create next.
Create the Chat app backend
Time to start creating the app. Create a file named index.js
with the following content:
/**
* 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)
}
The app won't do much yet, but that's OK. You'll add more functionality later.
Deploy the app
To deploy the "Hello world" app, you'll deploy the Cloud Function, configure the Chat app in Google Cloud Console, and send a test message to the app to verify deployment.
Deploy the Cloud Function
To deploy the "Hello world" app's Cloud Function, enter the following command:
gcloud functions deploy app --trigger-http --security-level=secure-always --allow-unauthenticated --runtime nodejs14
When finished, the output should look something like this:
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'
Note the URL of the deployed function in the httpsTrigger.url
property. You'll use this in the next step.
Configure the app
To configure the app, go to the Chat configuration page in Cloud Console
- In App name, enter "PollCodelab".
- In Avatar URL, enter
https://raw.githubusercontent.com/google/material-design-icons/master/png/social/poll/materialicons/24dp/2x/baseline_poll_black_24dp.png
. - In Description, enter "Poll app for codelab".
- Under Functionality, select Receive 1:1 messages and Join spaces and group conversations.
- Under Connection settings, select App URL and paste the URL for the Cloud Function (the
httpsTrigger.url
property from the last section). - Under Permissions, select Specific people and groups in your domain and enter your email address.
- Click save.
The app is now ready to message.
Test the app
Before moving on, check that the app is working by adding it to a space in Google Chat.
- Go to Google Chat.
- Next to Chat, click + > Find apps.
- Enter "PollCodelab" in search.
- Click Chat.
- To message the app, type "Hello" and press enter.
The app should respond with a brief hello message.
Now that there's a basic skeleton in place, time to turn it into something more useful!
5. Build the poll features
A quick overview of how the app will work
The app consists of two main parts:
- A slash command that displays a dialog for configuring the poll.
- An interactive card for voting and viewing results.
The app also needs some state to store the poll configuration and results. This could be done with Firestore or any database, or state can be stored in the app messages themselves. Since this app is intended for quick informal polls of a team, storing the state in the app messages works great for this use case.
The data model for the app (expressed in Typescript) is:
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 };
}
Besides the topic or question and a list of choices, the state includes the author's id and name as well as the recorded votes. To prevent users from voting multiple times, votes are stored as a map of user ids to the index of their selected choice.
There are, of course, many different approaches but this provides a good starting point for running quick polls in a space.
Implement the poll configuration command
To let users start and configure polls, set up a slash command that opens a dialog. This is a multi-step process:
- Register the slash command that starts a poll.
- Create the dialog that sets up a poll.
- Let the app recognize and handle the slash command.
- Create interactive cards that facilitate voting in the poll.
- Implement the code that lets the app run polls.
- Redeploy the cloud function.
Register the slash command
To register a slash command, go back to the Chat configuration page in the console (APIs & Services > Dashboard > Hangouts Chat API > Configuration).
- Under Slash commands, click Add a new slash command.
- In Name, enter "/poll"
- In Command id, enter "1"
- In Description, enter "Start a poll".
- Select Opens a dialog.
- Click Done.
- Click Save.
The app now recognizes the /poll
command, and it opens a dialog. Next, let's configure the dialog.
Create the configuration form as a dialog
The slash command opens a dialog to configure the poll topic and possible choices. Create a new file called config-form.js
with the following content:
/** 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;
This code generates the dialog form which lets the user set up the poll. It also exports a constant for the maximum number of choices a question can have. It's a good practice to isolate building the UI markup into stateless functions with any state passed in as parameters. It facilitates reuse, and later on this card will be rendered in different contexts.
This implementation also decomposes the card into smaller units or components. While not required, the technique is a best practice because it tends to be more readable and maintainable when building complex interfaces.
To see a sample of the complete JSON it builds, view it in the Card Builder tool.
Handle the slash command
Slash commands appear as MESSAGE
events when sent to the app. Update index.js
to check for the presence of a slash command via a MESSAGE
event and to respond with a dialog. Replace index.js
with the following:
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.',
},
},
},
}
}
The app will now display a dialog with when the /poll
command is invoked. Test the interaction by redploying the Cloud Function from Cloud Shell.
gcloud functions deploy app --trigger-http --security-level=secure-always
After the Cloud Function deploys, message the app with the /poll
command to test the slash command and dialog. The dialog sends a CARD_CLICKED
event with the custom action start_poll
. The event is handled in the updated entry point where it calls the startPoll
method. For now, the startPoll
method is stubbed out to just close the dialog. In the next section, you'll implement the voting functionality and connect all the parts together.
Implement the voting card
To implement the voting portion of the app, start by defining the interactive card that provides an interface for people to vote.
Implement the vote interface
Create a file named vote-card.js
with the following content:
/**
* 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;
The implementation is similar to the approach taken with the dialog, though the markup for interactive cards is slightly different than dialogs. As before, you can view a sample of the generated JSON in the Card Builder tool.
Implement the vote action
The voting card includes a button for each choice. The index of that choice, along with the serialized state of the poll, is attached to the button. The app receives a CARD_CLICKED
with the action vote
along with any data attached to the button as parameters.
Update index.js
with:
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],
}
}
The recordVote
method parses the stored state and updates it with the user's vote, then rerenders the card. The poll results are serialized and stored with the card each time it is updated.
Connect the pieces
The app is almost done. With the slash command implemented along with voting, the only thing left is to finish the startPoll
method.
But, there's a catch.
When the poll configuration is submitted, the app needs to perform two actions:
- Close the dialog.
- Post a new message to the space with the voting card.
Unfortunately, the direct reply to the HTTP request can only do one, and it must be the first one. To post the vote card, the app must use the Chat API to create a new message asynchronously.
Add the client library
Run the following command to update the app's dependencies to include the Google API client for Node.js.
npm install --save googleapis
Start the poll
Update index.js
to the final version below:
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],
}
}
Redeploy the function:
gcloud functions deploy app --trigger-http --security-level=secure-always
You should now be able to fully exercise the app. Try invoking the /poll
command provide a question and a few choices. After submitting, the poll card appears.
Cast your vote and see what happens.
Of course polling yourself isn't all that useful, so invite some friends or coworkers to give it a try!
6. Congratulations
Congratulations! You've successfully built and deployed a Google Chat app using Cloud Functions. While the codelab covered many of the core concepts for building an app, there's a lot more to explore. See the resources below and don't forget to clean up your project to avoid additional charges.
Additional activities
If you'd like to explore the Chat platform and this app in more depth, here are a few things you can try on your own:
- What happens when you @ mention the app? Try updating the app to improve the behavior.
- Serializing the poll state in the card is OK for small spaces, but has limits. Try switching to a better option.
- What if the author wants to edit the poll, or stop taking new votes? How would you implement those features?
- The app endpoint isn't secured yet. Try adding some verification to ensure the requests are coming from Google Chat.
These are just a few different ways to improve the app. Have fun and use your imagination!
Clean up
To avoid incurring charges to your Google Cloud Platform account for the resources used in this tutorial:
- In the Cloud Console, go to the Manage resources page. Click At the top-left corner, click Menu
> IAM & Admin > Manage Resources.
- In the project list, select your project then click Delete.
- In the dialog, type the project ID and then click Shut down to delete the project.
Learn more
For more information about developing Chat apps, see:
For more information about developing in Google Cloud Console, see: