1. Introducción
Los complementos de Google Workspace son aplicaciones personalizadas que se integran en las aplicaciones de Google Workspace, como Gmail, Documentos, Hojas de cálculo y Presentaciones. Permiten a los desarrolladores crear interfaces de usuario personalizadas que se integran directamente en Google Workspace. Los complementos ayudan a los usuarios a trabajar de manera más eficiente con menos cambios de contexto.
En este codelab, aprenderás a compilar e implementar un complemento simple de lista de tareas con Node.js, Cloud Run y Datastore.
Qué aprenderás
- Cómo usar Cloud Shell
- Implementa en Cloud Run
- Crea e implementa un descriptor de implementación de complemento
- Crea IU de complementos con el framework de tarjetas
- Cómo responder a las interacciones del usuario
- Aprovecha el contexto del usuario en un complemento
2. Configuración y requisitos
Sigue las instrucciones de configuración para crear un proyecto de Google Cloud y habilitar las APIs y los servicios que usará el complemento.
Configuración del entorno de autoaprendizaje
- Abre Cloud Console y crea un proyecto nuevo. (Si aún no tienes una cuenta de Gmail o de Google Workspace, crea una).
Recuerde el ID de proyecto, un nombre único en todos los proyectos de Google Cloud (el nombre anterior ya se encuentra en uso y no lo podrá usar). Se mencionará más adelante en este codelab como PROJECT_ID.
- A continuación, habilita la facturación en la consola de Cloud para usar los recursos de Google Cloud.
Ejecutar este codelab no debería costar mucho, tal vez nada. Asegúrate de seguir todas las instrucciones de la sección “Realiza una limpieza” al final del codelab, en la que se indica cómo cerrar los recursos para no incurrir en facturaciones más allá de este instructivo. Los usuarios nuevos de Google Cloud son aptos para participar en el programa Prueba gratuita de $300.
Google Cloud Shell
Si bien Google Cloud se puede operar de manera remota desde tu laptop, en este codelab usaremos Google Cloud Shell, un entorno de línea de comandos que se ejecuta en la nube.
Activar Cloud Shell
- En la consola de Cloud, haz clic en Activar Cloud Shell
.
La primera vez que abras Cloud Shell, verás un mensaje de bienvenida descriptivo. Si ves el mensaje de bienvenida, haz clic en Continuar. El mensaje de bienvenida no volverá a aparecer. Este es el mensaje de bienvenida:
El aprovisionamiento y la conexión a Cloud Shell solo tomará unos minutos. Después de conectarte, verás la terminal de Cloud Shell:
Esta máquina virtual está cargada con todas las herramientas de desarrollo que necesitas. Ofrece un directorio principal persistente de 5 GB y se ejecuta en Google Cloud, lo que permite mejorar considerablemente el rendimiento de la red y la autenticación. Todo tu trabajo en este codelab se puede hacer con un navegador o tu Chromebook.
Una vez que estés conectado a Cloud Shell, deberías ver que ya se autenticó y que el proyecto ya se configuró con tu ID de proyecto.
- En Cloud Shell, ejecuta el siguiente comando para confirmar que tienes la autenticación:
gcloud auth list
Si se te solicita que autorices a Cloud Shell a realizar una llamada a la API de GCP, haz clic en Autorizar.
Resultado del comando
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
Para establecer la cuenta activa, ejecuta el siguiente comando:
gcloud config set account <ACCOUNT>
Para confirmar que seleccionaste el proyecto correcto, ejecuta el siguiente comando en Cloud Shell:
gcloud config list project
Resultado del comando
[core] project = <PROJECT_ID>
Si no aparece el proyecto correcto, puedes establecerlo con este comando:
gcloud config set project <PROJECT_ID>
Resultado del comando
Updated property [core/project].
En el codelab, se usa una combinación de operaciones de línea de comandos y edición de archivos. Para editar archivos, puedes usar el editor de código integrado en Cloud Shell. Para ello, haz clic en el botón Abrir editor en el lado derecho de la barra de herramientas de Cloud Shell. También encontrarás editores populares, como vim y emacs, disponibles en Cloud Shell.
3. Habilita las APIs de Cloud Run, Datastore y de complementos
Habilita las API de Cloud
En Cloud Shell, habilita las APIs de Cloud para los componentes que se usarán:
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
Es posible que esta operación se tarde unos minutos en completarse.
Cuando se complete, aparecerá un mensaje de éxito similar a este:
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
Crea una instancia de almacén de datos
A continuación, habilita App Engine y crea una base de datos de Datastore. Habilitar App Engine es un requisito previo para usar Datastore, pero no usaremos App Engine para nada más.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
Crea una pantalla de consentimiento de OAuth
El complemento requiere el permiso del usuario para ejecutarse y realizar acciones en sus datos. Configura la pantalla de consentimiento del proyecto para habilitar esta opción. Para el codelab, configurarás la pantalla de consentimiento como una aplicación interna, lo que significa que no es para distribución pública, para comenzar.
- Abre la consola de Google Cloud en una pestaña o ventana nueva.
- Junto a "Google Cloud Console", haz clic en la flecha hacia abajo
y selecciona tu proyecto. - En la esquina superior izquierda, haz clic en Menú
. - Haz clic en APIs y servicios > Credenciales. Aparecerá la página de credenciales de tu proyecto.
- Haz clic en la pantalla de consentimiento de OAuth. Aparecerá la pantalla “Pantalla de consentimiento de OAuth”.
- En "Tipo de usuario", selecciona Interno. Si usas una cuenta de @gmail.com, selecciona Externa.
- Haz clic en Crear. Aparecerá la página "Editar registro de la app".
- Completa el formulario:
- En Nombre de la app, ingresa "Complemento de tareas pendientes".
- En Correo electrónico de asistencia del usuario, ingresa tu dirección de correo electrónico personal.
- En Información de contacto del desarrollador, ingresa tu dirección de correo electrónico personal.
- Haga clic en Guardar y continuar. Aparecerá un formulario de Scopes.
- En el formulario Permisos, haz clic en Guardar y continuar. Aparecerá un resumen.
- Haz clic en Volver al panel.
4. Crea el complemento inicial
Inicializa el proyecto
Para comenzar, crearás un complemento simple de "Hello World" y lo implementarás. Los complementos son servicios web que responden a solicitudes HTTPS y con una carga útil de JSON que describe la IU y las acciones que se deben realizar. En este complemento, usarás Node.js y el framework Express.
Para crear este proyecto de plantilla, usa Cloud Shell para crear un directorio nuevo llamado todo-add-on y navega hasta él:
mkdir ~/todo-add-on cd ~/todo-add-on
En este directorio, realizarás todo el trabajo del codelab.
Inicializa el proyecto de Node.js:
npm init
NPM hace varias preguntas sobre la configuración del proyecto, como el nombre y la versión. En cada pregunta, presiona ENTER para aceptar los valores predeterminados. El punto de entrada predeterminado es un archivo llamado index.js, que crearemos a continuación.
A continuación, instala el framework web de Express:
npm install --save express express-async-handler
Crea el backend del complemento
Es hora de comenzar a crear la app.
Crea un archivo llamado index.js. Para crear archivos, puedes usar el editor de Cloud Shell. Para ello, haz clic en el botón Abrir editor en la barra de herramientas de la ventana de Cloud Shell. Como alternativa, puedes editar y administrar archivos en Cloud Shell con vim o emacs.
Después de crear el archivo index.js, agrega el siguiente contenido:
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}`)
});
El servidor no hace mucho más que mostrar el mensaje “Hello World”, y eso está bien. Podrás agregarle más funciones más adelante.
Implementa en Cloud Run
Para implementar en Cloud Run, la app debe estar alojada en un contenedor.
Crea el contenedor
Crea un Dockerfile llamado Dockerfile que contenga lo siguiente:
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" ]
Cómo evitar que los archivos no deseados entren en el contenedor
Para que el contenedor sea liviano, crea un archivo .dockerignore que contenga lo siguiente:
Dockerfile
.dockerignore
node_modules
npm-debug.log
Habilitar Cloud Build
En este codelab, compilarás e implementarás el complemento varias veces a medida que se agregue nueva funcionalidad. En lugar de ejecutar comandos separados para compilar el contenedor, enviarlo a Container Registry y, luego, implementarlo en Cloud Run, usa Cloud Build para coordinar el procedimiento. Crea un archivo cloudbuild.yaml con instrucciones para compilar e implementar la aplicación:
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
Ejecuta los siguientes comandos para otorgar permiso a Cloud Build para implementar la 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
Compila e implementa el backend del complemento
Para iniciar la compilación, ejecuta el siguiente comando en Cloud Shell:
gcloud builds submit
La compilación y la implementación completas pueden tardar unos minutos en completarse, en especial la primera vez.
Una vez que se complete la compilación, verifica que el servicio se haya implementado y busca la URL. Ejecuta el siguiente comando:
gcloud run services list --platform managed
Copia esta URL, ya que la necesitarás en el siguiente paso para indicarle a Google Workspace cómo invocar el complemento.
Registra el complemento
Ahora que el servidor está en funcionamiento, describe el complemento para que Google Workspace sepa cómo mostrarlo y cómo invocarlo.
Crea un descriptor de implementación
Crea el archivo deployment.json con el siguiente contenido. Asegúrate de usar la URL de la app implementada en lugar del marcador de posición 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": {}
}
}
Sube el descriptor de implementación ejecutando el siguiente comando:
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
Autoriza el acceso al backend del complemento
El marco de trabajo de complementos también necesita permiso para llamar al servicio. Ejecuta los siguientes comandos para actualizar la política de IAM de Cloud Run y permitir que Google Workspace invoque el 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"
Instala el complemento para realizar pruebas
Para instalar el complemento en modo de desarrollo para tu cuenta, ejecuta lo siguiente en Cloud Shell:
gcloud workspace-add-ons deployments install todo-add-on
Abre (Gmail)[https://mail.google.com/] en una pestaña o ventana nueva. En el lado derecho, busca el complemento con un ícono de marca de verificación.

Para abrir el complemento, haz clic en el ícono de marca de verificación. Aparecerá un mensaje para autorizar el complemento.

Haz clic en Autorizar acceso y sigue las instrucciones del flujo de autorización en la ventana emergente. Una vez que se complete el proceso, el complemento se volverá a cargar automáticamente y mostrará el mensaje “Hello world!”.
¡Felicitaciones! Ahora tienes un complemento simple implementado e instalado. Es hora de convertirla en una aplicación de lista de tareas.
5. Accede a la identidad del usuario
Por lo general, muchos usuarios usan los complementos para trabajar con información privada para ellos o sus organizaciones. En este codelab, el complemento solo debe mostrar las tareas del usuario actual. La identidad del usuario se envía al complemento a través de un token de identidad que debe decodificarse.
Agrega permisos al descriptor de implementación
La identidad del usuario no se envía de forma predeterminada. Son datos del usuario, y el complemento necesita permiso para acceder a ellos. Para obtener ese permiso, actualiza deployment.json y agrega los permisos de OAuth openid y email a la lista de permisos que requiere el complemento. Después de agregar permisos de OAuth, el complemento les solicitará a los usuarios que otorguen acceso la próxima vez que lo usen.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
Luego, en Cloud Shell, ejecuta este comando para actualizar el descriptor de implementación:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Actualiza el servidor del complemento
Si bien el complemento está configurado para solicitar la identidad del usuario, aún se debe actualizar la implementación.
Analiza el token de identidad
Comienza por agregar la biblioteca de Google Auth al proyecto:
npm install --save google-auth-library
Luego, edita index.js para que requiera OAuth2Client:
const { OAuth2Client } = require('google-auth-library');
Luego, agrega un método de ayuda para analizar el 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();
}
Cómo mostrar la identidad del usuario
Este es un buen momento para crear un punto de control antes de agregar toda la funcionalidad de la lista de tareas. Actualiza la ruta de la app para imprimir la dirección de correo electrónico y el ID único del usuario en lugar 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);
}));
Después de estos cambios, el archivo index.js resultante debería verse así:
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}`)
});
Vuelve a implementar y realizar pruebas
Vuelve a compilar e implementar el complemento. En Cloud Shell, ejecuta el siguiente comando:
gcloud builds submit
Una vez que se vuelva a implementar el servidor, abre o vuelve a cargar Gmail y abre el complemento de nuevo. Dado que los permisos cambiaron, el complemento solicitará una nueva autorización. Vuelve a autorizar el complemento y, una vez que se complete el proceso, el complemento mostrará tu dirección de correo electrónico y tu ID de usuario.
Ahora que el complemento sabe quién es el usuario, puedes comenzar a agregar la funcionalidad de la lista de tareas.
6. Implementa la lista de tareas
El modelo de datos inicial del codelab es sencillo: una lista de entidades Task, cada una con propiedades para el texto descriptivo de la tarea y una marca de tiempo.
Crea el índice de Datastore
Datastore ya se habilitó para el proyecto anteriormente en el codelab. No requiere un esquema, aunque sí requiere la creación explícita de índices para las consultas compuestas. La creación del índice puede tardar unos minutos, por lo que lo harás primero.
Crea un archivo llamado index.yaml con el siguiente contenido:
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
Luego, actualiza los índices de Datastore:
gcloud datastore indexes create index.yaml
Cuando se te solicite continuar, presiona INTRO en el teclado. La creación del índice se realiza en segundo plano. Mientras eso sucede, comienza a actualizar el código del complemento para implementar las "tareas pendientes".
Actualiza el backend del complemento
Instala la biblioteca de Datastore en el proyecto:
npm install --save @google-cloud/datastore
Leer y escribir en Datastore
Actualiza index.js para implementar la lista de tareas pendientes. Para ello, comienza por importar la biblioteca de Datastore y crear el cliente:
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
Agrega métodos para leer y escribir tareas desde 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);
}
Implementa la renderización de la IU
La mayoría de los cambios se realizaron en la IU del complemento. Anteriormente, todas las tarjetas que devolvía la IU eran estáticas, es decir, no cambiaban según los datos disponibles. Aquí, la tarjeta debe construirse de forma dinámica en función de la lista de tareas actual del usuario.
La IU del codelab consta de una entrada de texto junto con una lista de tareas con casillas de verificación para marcarlas como completadas. Cada uno de estos también tiene una propiedad onChangeAction que genera una devolución de llamada al servidor del complemento cuando el usuario agrega o borra una tarea. En cada uno de estos casos, la IU debe volver a renderizarse con la lista de tareas actualizada. Para controlar esto, introduciremos un nuevo método para compilar la IU de la tarjeta.
Sigue editando index.js y agrega el siguiente 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;
}
Actualiza las rutas
Ahora que hay métodos auxiliares para leer y escribir en Datastore, y compilar la IU, conectémoslos en las rutas de la app. Reemplaza la ruta existente y agrega dos más: una para agregar tareas y otra para borrarlas.
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 es el archivo index.js final y completamente 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}`)
});
Vuelve a implementar y realizar pruebas
Para volver a compilar y volver a implementar el complemento, inicia una compilación. En Cloud Shell, ejecuta este comando:
gcloud builds submit
En Gmail, vuelve a cargar el complemento y aparecerá la nueva IU. Tómate un minuto para explorar el complemento. Agrega algunas tareas. Para ello, ingresa texto en el campo de entrada y presiona INTRO en el teclado. Luego, haz clic en la casilla de verificación para borrarlas.

Si quieres, puedes avanzar hasta el paso final de este codelab y limpiar tu proyecto. O bien, si quieres seguir aprendiendo sobre los complementos, puedes completar un paso más.
7. (Opcional) Agregar contexto
Una de las funciones más potentes de los complementos es el reconocimiento del contexto. Con el permiso del usuario, los complementos pueden acceder a contextos de Google Workspace, como el correo electrónico que está viendo un usuario, un evento de calendario y un documento. Los complementos también pueden realizar acciones como insertar contenido. En este codelab, agregarás compatibilidad con el contexto para los editores de Workspace (Documentos, Hojas de cálculo y Presentaciones) para adjuntar el documento actual a las tareas creadas mientras se usan los editores. Cuando se muestre la tarea, si haces clic en ella, se abrirá el documento en una pestaña nueva para que el usuario vuelva al documento y termine su tarea.
Actualiza el backend del complemento
Actualiza la ruta de newTask
Primero, actualiza la ruta /newTask para incluir el ID del documento en una tarea si está disponible:
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);
}));
Las tareas recién creadas ahora incluyen el ID del documento actual. Sin embargo, el contexto en los editores no se comparte de forma predeterminada. Al igual que con otros datos del usuario, este debe otorgar permiso para que el complemento acceda a los datos. Para evitar el exceso de uso compartido de información, el enfoque preferido es solicitar y otorgar permisos por archivo.
Cómo actualizar la IU
En index.js, actualiza buildCard para realizar dos cambios. La primera es actualizar la renderización de las tareas para incluir un vínculo al documento, si está presente. La segunda es mostrar un mensaje de autorización opcional si el complemento se renderiza en un editor y aún no se otorgó acceso al archivo.
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;
}
Implementa la ruta de autorización de archivos
El botón de autorización agrega una ruta nueva a la app, así que implementémosla. Esta ruta presenta un concepto nuevo: las acciones de la app host. Estas son instrucciones especiales para interactuar con la aplicación host del complemento. En este caso, para solicitar acceso al archivo del editor actual.
En index.js, agrega la ruta /authorizeFile:
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
Este es el archivo index.js final y completamente 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}`)
});
Agrega permisos al descriptor de implementación
Antes de volver a compilar el servidor, actualiza el descriptor de implementación del complemento para incluir el alcance de OAuth https://www.googleapis.com/auth/drive.file. Actualiza deployment.json para agregar https://www.googleapis.com/auth/drive.file a la lista de permisos de 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"
]
Sube la nueva versión ejecutando este comando de Cloud Shell:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Vuelve a implementar y realizar pruebas
Por último, se vuelve a compilar el servidor. En Cloud Shell, ejecuta el siguiente comando:
gcloud builds submit
Una vez que finalice, en lugar de abrir Gmail, abre un documento de Google existente o crea uno nuevo abriendo doc.new. Si creas un documento nuevo, asegúrate de ingresar texto o darle un nombre al archivo.
Abre el complemento. El complemento muestra un botón Authorize File Access en la parte inferior. Haz clic en el botón y, luego, autoriza el acceso al archivo.
Una vez que se autorice, agrega una tarea mientras estás en el editor. La tarea incluye una etiqueta que indica que el documento está adjunto. Si haces clic en el vínculo, se abrirá el documento en una pestaña nueva. Claro, abrir un documento que ya tienes abierto es un poco absurdo. Si quieres optimizar la IU para filtrar los vínculos del documento actual, considera que eso es crédito adicional.
8. Felicitaciones
¡Felicitaciones! Compilaste y, luego, implementaste un complemento de Google Workspace de manera correcta con Cloud Run. Si bien en el codelab se abordaron muchos de los conceptos fundamentales para desarrollar un complemento, hay mucho más por explorar. Consulta los recursos que están más abajo y no te olvides de limpiar tu proyecto para evitar cargos adicionales.
Limpia
Para desinstalar el complemento de tu cuenta, ejecuta este comando en Cloud Shell:
gcloud workspace-add-ons deployments uninstall todo-add-on
Sigue estos pasos para evitar que se apliquen cargos a tu cuenta de Google Cloud Platform para los recursos que se usaron en este instructivo:
- En la consola de Cloud, ve a la página Administrar recursos. Haz clic en la esquina superior izquierda, luego en Menú
> IAM y administración > Administra recursos.
- En la lista de proyectos, selecciona el tuyo y haz clic en Borrar.
- En el diálogo, escribe el ID del proyecto y, luego, haz clic en Cerrar para borrarlo.
Más información
- Descripción general de los complementos de Google Workspace
- Busca apps y complementos existentes en el Marketplace.