1. Introdução
Os complementos do Google Workspace são aplicativos personalizados que se integram aos apps do Google Workspace, como Gmail, Documentos, Planilhas e Apresentações. Com eles, os desenvolvedores podem criar interfaces do usuário personalizadas e integradas diretamente ao Google Workspace. Os complementos ajudam os usuários a trabalhar com mais eficiência e menos troca de contexto.
Neste codelab, você vai aprender a criar e implantar um complemento simples de lista de tarefas usando Node.js, Cloud Run e Datastore.
O que você vai aprender
- Como usar o Cloud Shell
- Implantar no Cloud Run
- Criar e implantar um descritor de implantação de complemento
- Criar interfaces de complementos com a estrutura de cards
- Responder às interações do usuário
- Aproveitar o contexto do usuário em um complemento
2. Configuração e requisitos
Siga as instruções de configuração para criar um projeto do Google Cloud e ativar as APIs e os serviços que o complemento vai usar.
Configuração de ambiente autoguiada
- Abra o Console do Cloud e crie um projeto. Crie uma conta do Gmail ou do Google Workspace, se ainda não tiver uma.
Lembre-se do código do projeto, um nome exclusivo em todos os projetos do Google Cloud. O nome acima já foi escolhido e não servirá para você. Faremos referência a ele mais adiante neste codelab como PROJECT_ID.
- Em seguida, para usar os recursos do Google Cloud, ative o faturamento no console do Cloud.
A execução deste codelab não será muito cara, se tiver algum custo. Siga todas as instruções da seção "Limpeza" no final do codelab, que orienta como encerrar recursos para não incorrer em cobranças além deste tutorial. Novos usuários do Google Cloud estão qualificados para o programa de US$ 300 de avaliação sem custos.
Google Cloud Shell
Embora o Google Cloud possa ser operado remotamente em seu laptop, neste codelab vamos usar o Google Cloud Shell, um ambiente de linha de comando executado no Cloud.
Ativar o Cloud Shell
- No Console do Cloud, clique em Ativar o Cloud Shell
.
Na primeira vez que você abre o Cloud Shell, uma mensagem de boas-vindas descritiva é exibida. Se a mensagem de boas-vindas aparecer, clique em Continuar. A mensagem de boas-vindas não vai aparecer de novo. Esta é a mensagem de boas-vindas:
Leva apenas alguns instantes para provisionar e se conectar ao Cloud Shell. Após a conexão, o terminal do Cloud Shell aparece:
Essa máquina virtual contém todas as ferramentas de desenvolvimento necessárias. Ela oferece um diretório principal persistente de 5 GB, além de ser executada no Google Cloud. Isso aprimora o desempenho e a autenticação da rede. Todo o trabalho neste codelab pode ser feito em um navegador ou no seu Chromebook.
Depois de se conectar ao Cloud Shell, você vai ver que sua conta já está autenticada e que o projeto está configurado com seu ID do projeto.
- Execute o seguinte comando no Cloud Shell para confirmar se a conta está autenticada:
gcloud auth list
Se for preciso autorizar o Cloud Shell a fazer uma chamada de API do GCP, clique em Autorizar.
Resposta ao comando
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
Para definir a conta ativa, execute:
gcloud config set account <ACCOUNT>
Para confirmar se você selecionou o projeto correto, execute este comando no Cloud Shell:
gcloud config list project
Resposta ao comando
[core] project = <PROJECT_ID>
Se o projeto correto não for retornado, configure-o com este comando:
gcloud config set project <PROJECT_ID>
Resposta ao comando
Updated property [core/project].
O codelab usa uma mistura de operações de linha de comando e edição de arquivos. Para editar arquivos, clique no botão Abrir editor, no lado direito da barra de ferramentas do Cloud Shell, para usar o editor de código integrado. Você também vai encontrar editores conhecidos, como vim e emacs, disponíveis no Cloud Shell.
3. Ativar as APIs Cloud Run, Datastore e de complementos
Ativar Cloud APIs
No Cloud Shell, ative as APIs do Cloud para os componentes que serão usados:
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
A conclusão dessa operação pode levar alguns instantes.
Depois de concluída, uma mensagem de sucesso semelhante a esta aparece:
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
Criar uma instância do Datastore
Em seguida, ative o App Engine e crie um banco de dados do Datastore. Ativar o App Engine é um pré-requisito para usar o Datastore, mas não vamos usar o App Engine para mais nada.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
Criar uma tela de permissão OAuth
O complemento precisa da permissão do usuário para ser executado e realizar ações nos dados dele. Configure a tela de permissão do projeto para ativar isso. Para o codelab, você vai configurar a tela de permissão como um aplicativo interno, ou seja, não para distribuição pública, para começar.
- Abra o Console do Google Cloud em uma nova guia ou janela.
- Ao lado de "Console do Google Cloud", clique na seta para baixo
e selecione seu projeto. - No canto superior esquerdo, clique em Menu
. - Clique em APIs e serviços > Credenciais. A página de credenciais do seu projeto vai aparecer.
- Clique na Tela de permissão OAuth. A tela "Tela de permissão OAuth" vai aparecer.
- Em "Tipo de usuário", selecione Interno. Se você estiver usando uma conta @gmail.com, selecione Externa.
- Clique em Criar. A página "Editar registro do app" aparece.
- Preencha o formulário:
- Em Nome do app, digite "Complemento de tarefas".
- Em E-mail para suporte do usuário, digite seu endereço de e-mail pessoal.
- Em Informações de contato do desenvolvedor, insira seu endereço de e-mail pessoal.
- Clique em Salvar e continuar. Um formulário "Escopos" vai aparecer.
- No formulário "Escopos", clique em Salvar e continuar. Um resumo vai aparecer.
- Clique em Voltar ao painel.
4. Criar o complemento inicial
Inicializar o projeto
Para começar, você vai criar e implantar um complemento "Hello world" simples. Os complementos são serviços da Web que respondem a solicitações https com um payload JSON que descreve a interface e as ações a serem realizadas. Neste complemento, você vai usar o Node.js e a biblioteca Express.
Para criar esse projeto de modelo, use o Cloud Shell para criar um novo diretório chamado todo-add-on e navegue até ele:
mkdir ~/todo-add-on cd ~/todo-add-on
Você vai fazer todo o trabalho do codelab neste diretório.
Inicialize o projeto Node.js:
npm init
O NPM faz várias perguntas sobre a configuração do projeto, como nome e versão. Para cada pergunta, pressione ENTER para aceitar os valores padrão. O ponto de entrada padrão é um arquivo chamado index.js, que vamos criar a seguir.
Em seguida, instale o framework da Web Express:
npm install --save express express-async-handler
Criar o back-end do complemento
É hora de começar a criar o app.
Crie um arquivo chamado index.js. Para criar arquivos, use o editor do Cloud Shell clicando no botão Abrir editor na barra de ferramentas da janela do Cloud Shell. Como alternativa, você pode editar e gerenciar arquivos no Cloud Shell usando vim ou emacs.
Depois de criar o arquivo index.js, adicione o seguinte conteúdo:
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}`)
});
O servidor não faz muito além de mostrar a mensagem "Hello world", e isso é suficiente. Você vai adicionar mais funcionalidades depois.
Implantar no Cloud Run
Para implantar no Cloud Run, o app precisa ser conteinerizado.
Criar o contêiner
Crie um Dockerfile chamado Dockerfile com o seguinte conteúdo:
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" ]
Evitar arquivos indesejados no contêiner
Para manter o contêiner leve, crie um arquivo .dockerignore com o seguinte conteúdo:
Dockerfile
.dockerignore
node_modules
npm-debug.log
Ativar o Cloud Build
Neste codelab, você vai criar e implantar o complemento várias vezes à medida que novas funcionalidades forem adicionadas. Em vez de executar comandos separados para criar o contêiner, enviá-lo ao Container Registry e implantá-lo no Cloud Build, use o Cloud Build para orquestrar o procedimento. Crie um arquivo cloudbuild.yaml com instruções sobre como criar e implantar o aplicativo:
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
Execute os comandos a seguir para conceder ao Cloud Build permissão para implantar o app:
PROJECT_ID=$(gcloud config list --format='value(core.project)')
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
--role=roles/run.admin
gcloud iam service-accounts add-iam-policy-binding \
$PROJECT_NUMBER-compute@developer.gserviceaccount.com \
--member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
--role=roles/iam.serviceAccountUser
Criar e implantar o back-end do complemento
Para iniciar o build, execute o seguinte no Cloud Shell:
gcloud builds submit
O build e a implantação completos podem levar alguns minutos para serem concluídos, principalmente na primeira vez.
Quando o build for concluído, verifique se o serviço foi implantado e encontre o URL. Execute o comando:
gcloud run services list --platform managed
Copie esse URL, porque você vai precisar dele na próxima etapa, que é informar ao Google Workspace como invocar o complemento.
Registrar o complemento
Agora que o servidor está funcionando, descreva o complemento para que o Google Workspace saiba como mostrar e invocar o complemento.
Criar um descritor de implantação
Crie o arquivo deployment.json com o seguinte conteúdo. Use o URL do app implantado no lugar do marcador 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": {}
}
}
Faça o upload do descritor de implantação executando o comando:
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
Autorizar o acesso ao back-end do complemento
O framework de complementos também precisa de permissão para chamar o serviço. Execute os comandos a seguir para atualizar a política do IAM do Cloud Run e permitir que o Google Workspace invoque o complemento:
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"
Instalar o complemento para teste
Para instalar o complemento no modo de desenvolvimento da sua conta, execute o seguinte comando no Cloud Shell:
gcloud workspace-add-ons deployments install todo-add-on
Abra o Gmail em uma nova guia ou janela. À direita, encontre o complemento com um ícone de marca de seleção.

Para abrir o complemento, clique no ícone de marca de seleção. Um aviso para autorizar o complemento vai aparecer.

Clique em Autorizar acesso e siga as instruções do fluxo de autorização no pop-up. Depois de concluído, o complemento é recarregado automaticamente e mostra a mensagem "Hello world!".
Parabéns! Agora você tem um complemento simples implantado e instalado. Agora é hora de transformá-lo em um aplicativo de lista de tarefas.
5. Acessar a identidade do usuário
Os complementos geralmente são usados por muitos usuários para trabalhar com informações particulares ou das organizações deles. Neste codelab, o complemento só vai mostrar as tarefas do usuário atual. A identidade do usuário é enviada ao complemento por um token de identidade que precisa ser decodificado.
Adicionar escopos ao descritor de implantação
A identidade do usuário não é enviada por padrão. São dados do usuário, e o complemento precisa de permissão para acessá-los. Para receber essa permissão, atualize deployment.json e adicione os escopos do OAuth openid e email à lista de escopos exigidos pelo complemento. Depois de adicionar escopos do OAuth, o complemento pede que os usuários concedam acesso na próxima vez que o usarem.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
Em seguida, no Cloud Shell, execute este comando para atualizar o descritor de implantação:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Atualizar o servidor de complementos
Embora o complemento esteja configurado para solicitar a identidade do usuário, a implementação ainda precisa ser atualizada.
Analisar o token de identidade
Comece adicionando a biblioteca de autenticação do Google ao projeto:
npm install --save google-auth-library
Em seguida, edite index.js para exigir OAuth2Client:
const { OAuth2Client } = require('google-auth-library');
Em seguida, adicione um método auxiliar para analisar o token de ID:
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
Mostrar a identidade do usuário
Este é um bom momento para um checkpoint antes de adicionar toda a funcionalidade da lista de tarefas. Atualize a rota do app para imprimir o endereço de e-mail e o ID exclusivo do usuário em vez de "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);
}));
Depois dessas mudanças, o arquivo index.js resultante vai ficar assim:
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}`)
});
Reimplantar e testar
Recrie e reimplante o complemento. No Cloud Shell, execute:
gcloud builds submit
Depois que o servidor for reimplantado, abra ou atualize o Gmail e abra o complemento novamente. Como os escopos mudaram, o complemento vai pedir uma nova autorização. Autorize o complemento novamente. Depois de concluir, ele vai mostrar seu endereço de e-mail e ID de usuário.
Agora que o complemento sabe quem é o usuário, você pode começar a adicionar a funcionalidade de lista de tarefas.
6. Implementar a lista de tarefas
O modelo de dados inicial para o codelab é simples: uma lista de entidades Task, cada uma com propriedades para o texto descritivo da tarefa e um carimbo de data/hora.
Criar o índice do armazenamento de dados
O Datastore já foi ativado para o projeto no início do codelab. Ele não exige um esquema, mas requer a criação explícita de índices para consultas compostas. A criação do índice pode levar alguns minutos. Por isso, vamos começar por ela.
Crie um arquivo chamado index.yaml com o seguinte conteúdo:
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
Em seguida, atualize os índices do Datastore:
gcloud datastore indexes create index.yaml
Quando for solicitado que você prossiga, pressione ENTER no teclado. A criação do índice acontece em segundo plano. Enquanto isso, comece a atualizar o código do complemento para implementar os "todos".
Atualizar o back-end do complemento
Instale a biblioteca do Datastore no projeto:
npm install --save @google-cloud/datastore
Ler e gravar no Datastore
Atualize index.js para implementar a lista de tarefas pendentes. Comece importando a biblioteca do Datastore e criando o cliente:
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
Adicione métodos para ler e gravar tarefas do 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);
}
Implementar a renderização da interface
A maioria das mudanças é na interface do complemento. Antes, todos os cards retornados pela interface eram estáticos e não mudavam de acordo com os dados disponíveis. Nesse caso, o card precisa ser criado dinamicamente com base na lista de tarefas atual do usuário.
A interface do codelab consiste em uma entrada de texto e uma lista de tarefas com caixas de seleção para marcar como concluídas. Cada um deles também tem uma propriedade onChangeAction que resulta em um callback no servidor do complemento quando o usuário adiciona ou exclui uma tarefa. Em cada um desses casos, a interface precisa ser renderizada novamente com a lista de tarefas atualizada. Para resolver isso, vamos apresentar um novo método de criação da interface do card.
Continue editando index.js e adicione o seguinte método:
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;
}
Atualizar as rotas
Agora que há métodos auxiliares para ler e gravar no Datastore e criar a interface, vamos conectá-los nas rotas do app. Substitua a rota atual e adicione mais duas: uma para adicionar tarefas e outra para excluir.
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);
}));
Este é o arquivo index.js final e totalmente funcional:
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}`)
});
Reimplantar e testar
Para recriar e reimplantar o complemento, inicie um build. No Cloud Shell, execute:
gcloud builds submit
No Gmail, recarregue o complemento para que a nova interface apareça. Reserve um minuto para conhecer o complemento. Adicione algumas tarefas digitando um texto na entrada e pressionando ENTER no teclado. Depois, clique na caixa de seleção para excluir.

Se quiser, pule para a etapa final deste codelab e limpe seu projeto. Se quiser continuar aprendendo mais sobre complementos, há mais uma etapa que você pode concluir.
7. (Opcional) Adicionar contexto
Um dos recursos mais avançados dos complementos é o reconhecimento de contexto. Com a permissão do usuário, os complementos podem acessar contextos do Google Workspace, como o e-mail que um usuário está lendo, um evento da agenda e um documento. Os complementos também podem realizar ações como inserir conteúdo. Neste codelab, você vai adicionar suporte a contexto para os editores do Workspace (Documentos, Planilhas e Apresentações) e anexar o documento atual a qualquer tarefa criada neles. Quando a tarefa for exibida, clique nela para abrir o documento em uma nova guia e voltar para concluir a tarefa.
Atualizar o back-end do complemento
Atualizar a rota newTask
Primeiro, atualize a rota /newTask para incluir o ID do documento em uma tarefa, se disponível:
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);
}));
As tarefas recém-criadas agora incluem o ID do documento atual. No entanto, o contexto nos editores não é compartilhado por padrão. Assim como outros dados do usuário, ele precisa conceder permissão para que o complemento acesse os dados. Para evitar o compartilhamento excessivo de informações, a abordagem preferida é solicitar e conceder permissão por arquivo.
Atualizar a interface
Em index.js, atualize buildCard para fazer duas mudanças. A primeira é atualizar a renderização das tarefas para incluir um link para o documento, se houver. A segunda é mostrar uma solicitação de autorização opcional se o complemento for renderizado em um editor e o acesso ao arquivo ainda não tiver sido concedido.
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;
}
Implementar a rota de autorização de arquivos
O botão de autorização adiciona uma nova rota ao app. Portanto, vamos implementá-la. Esta rota apresenta um novo conceito: ações do app host. São instruções especiais para interagir com o aplicativo host do complemento. Neste caso, para solicitar acesso ao arquivo do editor atual.
Em index.js, adicione a rota /authorizeFile:
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
Este é o arquivo index.js final e totalmente funcional:
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}`)
});
Adicionar escopos ao descritor de implantação
Antes de recriar o servidor, atualize o descritor de implantação do complemento para incluir o escopo OAuth https://www.googleapis.com/auth/drive.file. Atualize deployment.json para adicionar https://www.googleapis.com/auth/drive.file à lista de escopos do 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"
]
Faça o upload da nova versão executando este comando do Cloud Shell:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Reimplantar e testar
Por fim, recrie o servidor. No Cloud Shell, execute:
gcloud builds submit
Depois de concluir, em vez de abrir o Gmail, abra um documento Google ou crie um novo em doc.new. Se você estiver criando um documento, digite um texto ou dê um nome ao arquivo.
Abra o complemento. O complemento mostra um botão Autorizar acesso a arquivos na parte de baixo. Clique no botão e autorize o acesso ao arquivo.
Depois de autorizar, adicione uma tarefa no editor. A tarefa tem um rótulo indicando que o documento está anexado. Ao clicar no link, o documento é aberto em uma nova guia. É claro que abrir o documento que já está aberto é um pouco. Se você quiser otimizar a interface para filtrar links do documento atual, considere isso como um bônus!
8. Parabéns
Parabéns! Você criou e implantou um complemento do Google Workspace usando o Cloud Run. Embora o codelab tenha abordado muitos dos principais conceitos para a criação de um complemento, há muito mais a explorar. Confira os recursos abaixo e não se esqueça de limpar seu projeto para evitar cobranças adicionais.
Limpar
Para desinstalar o complemento da sua conta, execute este comando no Cloud Shell:
gcloud workspace-add-ons deployments uninstall todo-add-on
Para evitar que os recursos usados nesse tutorial sejam cobrados na sua conta do Google Cloud Platform:
- No console do Cloud, acesse a página Gerenciar recursos: No canto superior esquerdo, clique em Menu
> IAM e administrador > Gerenciar recurso.
- Na lista de projetos, selecione seu projeto e clique em Excluir.
- Na caixa de diálogo, digite o ID do projeto e clique em Encerrar para excluí-lo.
Saiba mais
- Visão geral dos complementos do Google Workspace
- Encontrar apps e complementos no marketplace