Создайте интерактивное приложение для опросов в Google Chat с помощью Node.js,Создайте интерактивное приложение для опросов в Google Chat с помощью Node.js

1. Введение

Приложения Google Chat переносят ваши сервисы и ресурсы прямо в Google Chat, позволяя пользователям получать информацию и быстро выполнять действия, не выходя из беседы.

В этой лабораторной работе вы узнаете, как создать и развернуть приложение для опроса с использованием Node.js и Cloud Functions .

Приложение публикует опрос для участников сообщества, спрашивающих, являются ли приложения крутыми, и собирает голоса с помощью интерактивного сообщения-открытки.

Чему вы научитесь

  • Используйте Cloud Shell
  • Развертывание в облачных функциях
  • Получайте пользовательский ввод с помощью слэш-команд и диалогов
  • Создавайте интерактивные карты

2. Настройка и требования

Создайте проект Google Cloud, затем включите API и службы, которые будет использовать приложение Chat.

Предпосылки

Для разработки приложения Google Chat требуется учётная запись Google Workspace с доступом к Google Chat. Если у вас ещё нет учётной записи Google Workspace, создайте её и войдите в систему, прежде чем приступать к выполнению этой практической работы.

Настройка среды для самостоятельного обучения

  1. Откройте Google Cloud Console и создайте проект .

    Меню выбора проектаНовая кнопка «Проект»Идентификатор проекта

    Запомните идентификатор проекта — уникальное имя для всех проектов Google Cloud (имя, указанное выше, уже занято и не будет вам работать, извините!). Далее в этой практической работе он будет обозначаться как PROJECT_ID .
  1. Далее, чтобы использовать ресурсы Google Cloud, включите биллинг в Cloud Console.

Выполнение этой лабораторной работы не должно обойтись дорого, если вообще обойтись. Обязательно следуйте инструкциям в разделе «Очистка» в конце лабораторной работы, где рассказывается, как отключить ресурсы, чтобы не платить за время, превышающее это руководство. Новые пользователи Google Cloud могут воспользоваться бесплатной пробной версией стоимостью 300 долларов США .

Google Cloud Shell

Хотя Google Cloud можно управлять удаленно с вашего ноутбука, в этой лабораторной работе мы будем использовать Google Cloud Shell — среду командной строки, работающую в Google Cloud.

Активировать Cloud Shell

  1. В консоли Cloud нажмите «Активировать Cloud Shell» . Значок Cloud Shell .

    Значок Cloud Shell в строке меню

    При первом открытии Cloud Shell вы увидите подробное приветственное сообщение. Если вы его видите, нажмите «Продолжить» . Больше приветственное сообщение не появляется. Вот текст приветствия:

    Приветственное сообщение Cloud Shell

    Подготовка и подключение к Cloud Shell займёт всего несколько минут. После подключения вы увидите терминал Cloud Shell:

    Терминал Cloud Shell

    Эта виртуальная машина оснащена всеми необходимыми инструментами разработки. Она предоставляет постоянный домашний каталог объёмом 5 ГБ и работает в Google Cloud, что значительно повышает производительность сети и аутентификацию. Всю работу в этой лабораторной работе можно выполнять в браузере или на Chromebook. После подключения к Cloud Shell вы увидите, что аутентификация выполнена, а проекту присвоен ваш идентификатор.
  2. Выполните следующую команду в Cloud Shell, чтобы подтвердить, что вы прошли аутентификацию:
    gcloud auth list
    
    Если вам будет предложено авторизовать Cloud Shell для выполнения вызова API GCP, нажмите Авторизовать .

    Вывод команды
    Credentialed Accounts
    ACTIVE  ACCOUNT
    *       <my_account>@<my_domain.com>
    
    Если ваша учетная запись не выбрана по умолчанию, выполните:
    $ gcloud config set account <ACCOUNT>
    
  1. Убедитесь, что вы выбрали правильный проект. В Cloud Shell выполните:
    gcloud config list project
    
    Вывод команды
    [core]
    project = <PROJECT_ID>
    
    Если правильный проект не возвращен, вы можете установить его с помощью этой команды:
    gcloud config set project <PROJECT_ID>
    
    Вывод команды
    Updated property [core/project].
    

В ходе выполнения этой лабораторной работы вы будете использовать операции командной строки и редактировать файлы. Для редактирования файлов вы можете использовать встроенный редактор кода Cloud Shell — Cloud Shell Editor , нажав кнопку «Открыть редактор» в правой части панели инструментов Cloud Shell. В Cloud Shell также доступны популярные редакторы, такие как Vim и Emacs.

3. Включите API Cloud Functions, Cloud Build и Google Chat.

В Cloud Shell включите следующие API и сервисы:

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

Выполнение этой операции может занять несколько минут.

После завершения появится сообщение об успешном завершении, похожее на это:

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

4. Создайте начальное приложение чата.

Инициализировать проект

Для начала вы создадите и развернёте простое приложение «Hello world». Чат-приложения — это веб-сервисы, которые отвечают на https-запросы и отправляют полезные данные в формате JSON. Для этого приложения вы будете использовать Node.js и Cloud Functions .

В Cloud Shell создайте новый каталог с именем poll-app и перейдите в него:

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

Вся оставшаяся работа по кодовой лаборатории и файлы, которые вы создадите, будут находиться в этом каталоге.

Инициализируйте проект Node.js:

npm init

NPM задаёт несколько вопросов о конфигурации проекта, таких как имя и версия. Для каждого вопроса нажмите клавишу ENTER , чтобы принять значения по умолчанию. Точкой входа по умолчанию является файл index.js , который мы создадим далее.

Создайте бэкэнд приложения чата

Пришло время начать создавать приложение. Создайте файл index.js со следующим содержимым:

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

Приложение пока не может многого сделать, но это нормально. Позже вы добавите больше функций.

Разверните приложение

Чтобы развернуть приложение «Hello world», вам нужно развернуть облачную функцию, настроить приложение чата в Google Cloud Console и отправить тестовое сообщение в приложение для проверки развертывания.

Развертывание облачной функции

Чтобы развернуть облачную функцию приложения «Hello world», введите следующую команду:

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

После завершения вывод должен выглядеть примерно так:

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

Запишите URL-адрес развёрнутой функции в свойстве httpsTrigger.url . Он понадобится вам на следующем шаге.

Настройте приложение

Чтобы настроить приложение, перейдите на страницу настройки чата в Cloud Console.

  1. В поле «Имя приложения » введите «PollCodelab».
  2. В поле URL аватара введите https://raw.githubusercontent.com/google/material-design-icons/master/png/social/poll/materialicons/24dp/2x/baseline_poll_black_24dp.png .
  3. В поле Описание введите «Приложение для опроса по кодлабу».
  4. В разделе «Функциональность» выберите «Принимать сообщения 1:1» и «Присоединяйтесь к пространствам и групповым беседам» .
  5. В разделе «Настройки подключения» выберите URL-адрес конечной точки HTTP и вставьте URL-адрес для облачной функции (свойство httpsTrigger.url из последнего раздела).
  6. В разделе «Разрешения» выберите «Определенные люди и группы в вашем домене» и введите свой адрес электронной почты.
  7. Нажмите «Сохранить» .

Теперь приложение готово к отправке сообщений.

Протестируйте приложение

Прежде чем двигаться дальше, проверьте работу приложения, добавив его в чат Google.

  1. Перейдите в Google Чат .
  2. Рядом с Чатом нажмите + > Найти приложения .
  3. Введите «PollCodelab» в поиске.
  4. Нажмите «Чат» .
  5. Чтобы отправить сообщение приложению, введите «Привет» и нажмите Enter.

Приложение должно ответить кратким приветственным сообщением.

Теперь, когда базовый скелет готов, пора превратить его во что-то более полезное!

5. Создайте функции опроса

Краткий обзор того, как будет работать приложение

Приложение состоит из двух основных частей:

  1. Команда с косой чертой, которая отображает диалоговое окно для настройки опроса.
  2. Интерактивная карточка для голосования и просмотра результатов.

Приложению также необходимо хранить состояние для конфигурации и результатов опроса. Это можно сделать с помощью Firestore или любой другой базы данных, либо хранить состояние в самих сообщениях приложения. Поскольку приложение предназначено для быстрых неформальных опросов в команде, сохранение состояния в сообщениях приложения отлично подходит для этого варианта использования.

Модель данных для приложения (выраженная в Typescript) следующая:

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

Помимо темы или вопроса и списка вариантов ответа, состояние включает идентификатор и имя автора, а также зарегистрированные голоса. Чтобы предотвратить многократное голосование, голоса хранятся в виде сопоставления идентификаторов пользователей с индексом выбранного ими варианта ответа.

Конечно, существует множество различных подходов, но этот вариант служит хорошей отправной точкой для проведения быстрых опросов в определенном пространстве.

Реализуйте команду конфигурации опроса

Чтобы пользователи могли запускать и настраивать опросы, создайте команду , открывающую диалоговое окно . Это многоэтапный процесс:

  1. Зарегистрируйте команду «слэш», которая запускает опрос.
  2. Создайте диалоговое окно для настройки опроса.
  3. Позвольте приложению распознать и обработать команду «слэш».
  4. Создавайте интерактивные карточки, облегчающие голосование в опросе.
  5. Реализуйте код, который позволит приложению проводить опросы.
  6. Повторно развернуть облачную функцию.

Зарегистрируйте команду «косая черта»

Чтобы зарегистрировать слэш-команду, вернитесь на страницу конфигурации чата в консоли ( API и службы > Панель управления > API чата Hangouts > Конфигурация ).

  1. В разделе «Команды косой черты» нажмите «Добавить новую команду косой черты» .
  2. В поле «Имя» введите «/poll».
  3. В поле «Идентификатор команды» введите «1».
  4. В поле «Описание» введите «Начать опрос».
  5. Выбрать Открывает диалоговое окно .
  6. Нажмите Готово .
  7. Нажмите «Сохранить» .

Теперь приложение распознаёт команду /poll и открывает диалоговое окно. Теперь давайте настроим диалоговое окно.

Создайте форму конфигурации как диалоговое окно

Команда с косой чертой открывает диалоговое окно для настройки темы опроса и возможных вариантов ответа. Создайте новый файл config-form.js со следующим содержимым:

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

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

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

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

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

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

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

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

Этот код генерирует диалоговую форму, позволяющую пользователю настроить опрос. Он также экспортирует константу для максимального количества вариантов ответа в вопросе. Рекомендуется изолировать разметку пользовательского интерфейса, разместив её в функциях без сохранения состояния, передавая любое состояние в качестве параметров. Это облегчает повторное использование, и впоследствии эта карточка будет отображаться в разных контекстах.

Эта реализация также разбивает карту на более мелкие блоки или компоненты. Хотя это не обязательно, этот метод рекомендуется, поскольку он, как правило, более удобен для чтения и поддержки при создании сложных интерфейсов.

Чтобы увидеть пример полного JSON-файла, который он создает, просмотрите его в инструменте Card Builder .

Обработка команды «косая черта»

При отправке в приложение слэш-команды отображаются как события MESSAGE . Обновите index.js , чтобы проверять наличие слэш-команды с помощью события MESSAGE и отвечать диалоговым окном. Замените index.js следующим:

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

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

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

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

Теперь приложение будет отображать диалоговое окно при вызове команды /poll . Проверьте взаимодействие, повторно развернув облачную функцию из Cloud Shell.

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

После развертывания облачной функции отправьте приложению команду /poll , чтобы протестировать работу команды слэша и диалогового окна. Диалоговое окно отправляет событие CARD_CLICKED с пользовательским действием start_poll . Событие обрабатывается в обновлённой точке входа, где вызывается метод startPoll . На данный момент метод startPoll заглушён, чтобы просто закрыть диалоговое окно. В следующем разделе вы реализуете функционал голосования и соедините все компоненты.

Внедрить карту для голосования

Чтобы реализовать часть приложения, связанную с голосованием, начните с определения интерактивной карты, которая предоставит людям интерфейс для голосования.

Реализовать интерфейс голосования

Создайте файл с именем vote-card.js со следующим содержимым:

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

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

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

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

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

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

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

exports.buildVoteCard = buildVoteCard;

Реализация аналогична подходу, использованному в диалоге, хотя разметка для интерактивных карточек немного отличается от разметки для диалогов. Как и прежде, вы можете просмотреть пример сгенерированного JSON-кода в инструменте Card Builder .

Реализовать действие голосования

Карточка для голосования включает кнопку для каждого варианта. К кнопке прикреплён индекс этого варианта и сериализованное состояние опроса. Приложение получает CARD_CLICKED с vote за действие и любыми данными, прикреплёнными к кнопке в качестве параметров.

Обновите index.js следующим образом:

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

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

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

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

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

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

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

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

Метод recordVote анализирует сохранённое состояние и обновляет его, используя голос пользователя, а затем повторно отображает карточку. Результаты опроса сериализуются и сохраняются вместе с карточкой при каждом её обновлении.

Соедини части

Приложение почти готово. После реализации команды «слэш» и голосования осталось только доработать метод startPoll .

Но есть одна загвоздка.

После отправки конфигурации опроса приложению необходимо выполнить два действия:

  1. Закройте диалоговое окно.
  2. Опубликуйте новое сообщение в поле с карточкой для голосования.

К сожалению, прямой ответ на HTTP-запрос может быть только один, и он должен быть первым. Чтобы опубликовать карточку голосования, приложение должно использовать API чата для асинхронного создания нового сообщения.

Добавить клиентскую библиотеку

Выполните следующую команду, чтобы обновить зависимости приложения и включить клиент Google API для Node.js.

npm install --save googleapis

Начать опрос

Обновите index.js до финальной версии ниже:

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

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

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

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

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

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

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

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

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

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

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

Повторно разверните функцию:

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

Теперь вы можете полноценно протестировать приложение. Попробуйте выполнить команду /poll задайте вопрос и выберите несколько вариантов ответа. После отправки появится карточка опроса.

Проголосуйте и посмотрите, что произойдет.

Конечно, проводить опрос самому себе не так уж полезно, поэтому пригласите друзей или коллег попробовать это сделать!

6. Поздравления

Поздравляем! Вы успешно создали и развернули приложение Google Chat с помощью Cloud Functions. Хотя в этой лабораторной работе были рассмотрены многие основные концепции создания приложения, многое ещё предстоит изучить. Ознакомьтесь с ресурсами ниже и не забудьте привести свой проект в порядок, чтобы избежать дополнительных расходов.

Дополнительные мероприятия

Если вы хотите более подробно изучить платформу чата и это приложение, вот несколько вещей, которые вы можете попробовать самостоятельно:

  • Что происходит, когда вы упоминаете приложение через @? Попробуйте обновить приложение, чтобы улучшить поведение.
  • Сериализация состояния опроса в карточке подходит для небольших пространств, но имеет ограничения. Попробуйте выбрать более подходящий вариант.
  • Что делать, если автор захочет отредактировать опрос или прекратить приём новых голосов? Как реализовать эти функции?
  • Конечная точка приложения пока не защищена. Попробуйте добавить проверку, чтобы убедиться, что запросы поступают из Google Chat.

Это лишь несколько способов улучшить приложение. Развлекайтесь и дайте волю фантазии!

Уборка

Чтобы избежать списания средств с вашего аккаунта Google Cloud Platform за ресурсы, используемые в этом руководстве:

  • В консоли Cloud Console перейдите на страницу «Управление ресурсами» . В левом верхнем углу нажмите «Меню». значок меню > IAM и администрирование > Управление ресурсами .
  1. В списке проектов выберите свой проект и нажмите «Удалить» .
  2. В диалоговом окне введите идентификатор проекта, а затем нажмите кнопку «Завершить» , чтобы удалить проект.

Узнать больше

Дополнительную информацию о разработке чат-приложений см. в разделах:

Дополнительную информацию о разработке в Google Cloud Console см. здесь:

,

1. Введение

Приложения Google Chat переносят ваши сервисы и ресурсы прямо в Google Chat, позволяя пользователям получать информацию и быстро выполнять действия, не выходя из беседы.

В этой лабораторной работе вы узнаете, как создать и развернуть приложение для опроса с использованием Node.js и Cloud Functions .

Приложение публикует опрос для участников сообщества, спрашивающих, являются ли приложения крутыми, и собирает голоса с помощью интерактивного сообщения-открытки.

Чему вы научитесь

  • Используйте Cloud Shell
  • Развертывание в облачных функциях
  • Получайте пользовательский ввод с помощью слэш-команд и диалогов
  • Создавайте интерактивные карты

2. Настройка и требования

Создайте проект Google Cloud, затем включите API и службы, которые будет использовать приложение Chat.

Предпосылки

Для разработки приложения Google Chat требуется учётная запись Google Workspace с доступом к Google Chat. Если у вас ещё нет учётной записи Google Workspace, создайте её и войдите в систему, прежде чем приступать к выполнению этой практической работы.

Настройка среды для самостоятельного обучения

  1. Откройте Google Cloud Console и создайте проект .

    Меню выбора проектаНовая кнопка «Проект»Идентификатор проекта

    Запомните идентификатор проекта — уникальное имя для всех проектов Google Cloud (имя, указанное выше, уже занято и не будет вам работать, извините!). Далее в этой практической работе он будет обозначаться как PROJECT_ID .
  1. Далее, чтобы использовать ресурсы Google Cloud, включите биллинг в Cloud Console.

Выполнение этой лабораторной работы не должно обойтись дорого, если вообще обойтись. Обязательно следуйте инструкциям в разделе «Очистка» в конце лабораторной работы, где рассказывается, как отключить ресурсы, чтобы не платить за время, превышающее это руководство. Новые пользователи Google Cloud могут воспользоваться бесплатной пробной версией стоимостью 300 долларов США .

Google Cloud Shell

Хотя Google Cloud можно управлять удаленно с вашего ноутбука, в этой лабораторной работе мы будем использовать Google Cloud Shell — среду командной строки, работающую в Google Cloud.

Активировать Cloud Shell

  1. В консоли Cloud нажмите «Активировать Cloud Shell» . Значок Cloud Shell .

    Значок Cloud Shell в строке меню

    При первом открытии Cloud Shell вы увидите подробное приветственное сообщение. Если вы его видите, нажмите «Продолжить» . Больше приветственное сообщение не появляется. Вот текст приветствия:

    Приветственное сообщение Cloud Shell

    Подготовка и подключение к Cloud Shell займёт всего несколько минут. После подключения вы увидите терминал Cloud Shell:

    Терминал Cloud Shell

    Эта виртуальная машина оснащена всеми необходимыми инструментами разработки. Она предоставляет постоянный домашний каталог объёмом 5 ГБ и работает в Google Cloud, что значительно повышает производительность сети и аутентификацию. Всю работу в этой лабораторной работе можно выполнять в браузере или на Chromebook. После подключения к Cloud Shell вы увидите, что аутентификация выполнена, а проекту присвоен ваш идентификатор.
  2. Выполните следующую команду в Cloud Shell, чтобы подтвердить, что вы прошли аутентификацию:
    gcloud auth list
    
    Если вам будет предложено авторизовать Cloud Shell для выполнения вызова API GCP, нажмите Авторизовать .

    Вывод команды
    Credentialed Accounts
    ACTIVE  ACCOUNT
    *       <my_account>@<my_domain.com>
    
    Если ваша учетная запись не выбрана по умолчанию, выполните:
    $ gcloud config set account <ACCOUNT>
    
  1. Убедитесь, что вы выбрали правильный проект. В Cloud Shell выполните:
    gcloud config list project
    
    Вывод команды
    [core]
    project = <PROJECT_ID>
    
    Если правильный проект не возвращен, вы можете установить его с помощью этой команды:
    gcloud config set project <PROJECT_ID>
    
    Вывод команды
    Updated property [core/project].
    

В ходе выполнения этой лабораторной работы вы будете использовать операции командной строки и редактировать файлы. Для редактирования файлов вы можете использовать встроенный редактор кода Cloud Shell — Cloud Shell Editor , нажав кнопку «Открыть редактор» в правой части панели инструментов Cloud Shell. В Cloud Shell также доступны популярные редакторы, такие как Vim и Emacs.

3. Включите API Cloud Functions, Cloud Build и Google Chat.

В Cloud Shell включите следующие API и сервисы:

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

Выполнение этой операции может занять несколько минут.

После завершения появится сообщение об успешном завершении, похожее на это:

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

4. Создайте начальное приложение чата.

Инициализировать проект

Для начала вы создадите и развернёте простое приложение «Hello world». Чат-приложения — это веб-сервисы, которые отвечают на https-запросы и отправляют полезные данные в формате JSON. Для этого приложения вы будете использовать Node.js и Cloud Functions .

В Cloud Shell создайте новый каталог с именем poll-app и перейдите в него:

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

Вся оставшаяся работа по кодовой лаборатории и файлы, которые вы создадите, будут находиться в этом каталоге.

Инициализируйте проект Node.js:

npm init

NPM задаёт несколько вопросов о конфигурации проекта, таких как имя и версия. Для каждого вопроса нажмите клавишу ENTER , чтобы принять значения по умолчанию. Точкой входа по умолчанию является файл index.js , который мы создадим далее.

Создайте бэкэнд приложения чата

Пришло время начать создавать приложение. Создайте файл index.js со следующим содержимым:

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

Приложение пока не может многого сделать, но это нормально. Позже вы добавите больше функций.

Разверните приложение

Чтобы развернуть приложение «Hello world», вам нужно развернуть облачную функцию, настроить приложение чата в Google Cloud Console и отправить тестовое сообщение в приложение для проверки развертывания.

Развертывание облачной функции

Чтобы развернуть облачную функцию приложения «Hello world», введите следующую команду:

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

После завершения вывод должен выглядеть примерно так:

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

Запишите URL-адрес развёрнутой функции в свойстве httpsTrigger.url . Он понадобится вам на следующем шаге.

Настройте приложение

Чтобы настроить приложение, перейдите на страницу настройки чата в Cloud Console.

  1. В поле «Имя приложения» введите «PollCodelab».
  2. В поле URL аватара введите https://raw.githubusercontent.com/google/material-design-icons/master/png/social/poll/materialicons/24dp/2x/baseline_poll_black_24dp.png .
  3. В поле Описание введите «Приложение для опроса по кодлабу».
  4. В разделе «Функциональность» выберите «Принимать сообщения 1:1» и «Присоединяйтесь к пространствам и групповым беседам» .
  5. В разделе «Настройки подключения» выберите URL-адрес конечной точки HTTP и вставьте URL-адрес для облачной функции (свойство httpsTrigger.url из последнего раздела).
  6. В разделе «Разрешения» выберите «Определенные люди и группы в вашем домене» и введите свой адрес электронной почты.
  7. Нажмите «Сохранить» .

Теперь приложение готово к отправке сообщений.

Протестируйте приложение

Прежде чем двигаться дальше, проверьте работу приложения, добавив его в чат Google.

  1. Перейдите в Google Чат .
  2. Рядом с Чатом нажмите + > Найти приложения .
  3. Введите «PollCodelab» в поиске.
  4. Нажмите «Чат» .
  5. Чтобы отправить сообщение приложению, введите «Привет» и нажмите Enter.

Приложение должно ответить кратким приветственным сообщением.

Теперь, когда базовый скелет готов, пора превратить его во что-то более полезное!

5. Создайте функции опроса

Краткий обзор того, как будет работать приложение

Приложение состоит из двух основных частей:

  1. Команда с косой чертой, которая отображает диалоговое окно для настройки опроса.
  2. Интерактивная карточка для голосования и просмотра результатов.

Приложению также необходимо хранить состояние для конфигурации и результатов опроса. Это можно сделать с помощью Firestore или любой другой базы данных, либо хранить состояние в самих сообщениях приложения. Поскольку приложение предназначено для быстрых неформальных опросов в команде, сохранение состояния в сообщениях приложения отлично подходит для этого варианта использования.

Модель данных для приложения (выраженная в Typescript) следующая:

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

Помимо темы или вопроса и списка вариантов ответа, состояние включает идентификатор и имя автора, а также зарегистрированные голоса. Чтобы предотвратить многократное голосование, голоса хранятся в виде сопоставления идентификаторов пользователей с индексом выбранного ими варианта ответа.

Конечно, существует множество различных подходов, но этот вариант служит хорошей отправной точкой для проведения быстрых опросов в определенном пространстве.

Реализуйте команду конфигурации опроса

Чтобы пользователи могли запускать и настраивать опросы, создайте команду , открывающую диалоговое окно . Это многоэтапный процесс:

  1. Зарегистрируйте команду «слэш», которая запускает опрос.
  2. Создайте диалоговое окно для настройки опроса.
  3. Позвольте приложению распознать и обработать команду «слэш».
  4. Создавайте интерактивные карточки, облегчающие голосование в опросе.
  5. Реализуйте код, который позволит приложению проводить опросы.
  6. Повторно развернуть облачную функцию.

Зарегистрируйте команду «косая черта»

Чтобы зарегистрировать слэш-команду, вернитесь на страницу конфигурации чата в консоли ( API и службы > Панель управления > API чата Hangouts > Конфигурация ).

  1. В разделе «Команды косой черты» нажмите «Добавить новую команду косой черты» .
  2. В поле «Имя» введите «/poll».
  3. В поле «Идентификатор команды» введите «1».
  4. В поле «Описание» введите «Начать опрос».
  5. Выбрать Открывает диалоговое окно .
  6. Нажмите Готово .
  7. Нажмите «Сохранить» .

Теперь приложение распознаёт команду /poll и открывает диалоговое окно. Теперь давайте настроим диалоговое окно.

Создайте форму конфигурации как диалоговое окно

Команда с косой чертой открывает диалоговое окно для настройки темы опроса и возможных вариантов ответа. Создайте новый файл config-form.js со следующим содержимым:

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

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

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

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

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

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

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

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

Этот код генерирует диалоговую форму, позволяющую пользователю настроить опрос. Он также экспортирует константу для максимального количества вариантов ответа в вопросе. Рекомендуется изолировать разметку пользовательского интерфейса, разместив её в функциях без сохранения состояния, передавая любое состояние в качестве параметров. Это облегчает повторное использование, и впоследствии эта карточка будет отображаться в разных контекстах.

Эта реализация также разбивает карту на более мелкие блоки или компоненты. Хотя это не обязательно, этот метод рекомендуется, поскольку он, как правило, более удобен для чтения и поддержки при создании сложных интерфейсов.

Чтобы увидеть пример полного JSON-файла, который он создает, просмотрите его в инструменте Card Builder .

Обработка команды «косая черта»

При отправке в приложение слэш-команды отображаются как события MESSAGE . Обновите index.js , чтобы проверять наличие слэш-команды с помощью события MESSAGE и отвечать диалоговым окном. Замените index.js следующим:

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

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

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

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

Теперь приложение будет отображать диалоговое окно при вызове команды /poll . Проверьте взаимодействие, повторно развернув облачную функцию из Cloud Shell.

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

После развертывания облачной функции отправьте приложению команду /poll , чтобы протестировать работу команды слэша и диалогового окна. Диалоговое окно отправляет событие CARD_CLICKED с пользовательским действием start_poll . Событие обрабатывается в обновлённой точке входа, где вызывается метод startPoll . На данный момент метод startPoll заглушён, чтобы просто закрыть диалоговое окно. В следующем разделе вы реализуете функционал голосования и соедините все компоненты.

Внедрить карту для голосования

Чтобы реализовать часть приложения, связанную с голосованием, начните с определения интерактивной карты, которая предоставит людям интерфейс для голосования.

Реализовать интерфейс голосования

Создайте файл с именем vote-card.js со следующим содержимым:

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

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

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

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

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

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

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

exports.buildVoteCard = buildVoteCard;

Реализация аналогична подходу, использованному в диалоге, хотя разметка для интерактивных карточек немного отличается от разметки для диалогов. Как и прежде, вы можете просмотреть пример сгенерированного JSON-кода в инструменте Card Builder .

Реализовать действие голосования

Карточка для голосования включает кнопку для каждого варианта. К кнопке прикреплён индекс этого варианта и сериализованное состояние опроса. Приложение получает CARD_CLICKED с vote за действие и любыми данными, прикреплёнными к кнопке в качестве параметров.

Обновите index.js следующим образом:

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

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

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

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

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

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

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

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

Метод recordVote анализирует сохранённое состояние и обновляет его, используя голос пользователя, а затем повторно отображает карточку. Результаты опроса сериализуются и сохраняются вместе с карточкой при каждом её обновлении.

Соедини части

Приложение почти готово. После реализации команды «слэш» и голосования осталось только доработать метод startPoll .

Но есть одна загвоздка.

После отправки конфигурации опроса приложению необходимо выполнить два действия:

  1. Закройте диалоговое окно.
  2. Опубликуйте новое сообщение в поле с карточкой для голосования.

К сожалению, прямой ответ на HTTP-запрос может быть только один, и он должен быть первым. Чтобы опубликовать карточку голосования, приложение должно использовать API чата для асинхронного создания нового сообщения.

Добавить клиентскую библиотеку

Выполните следующую команду, чтобы обновить зависимости приложения и включить клиент Google API для Node.js.

npm install --save googleapis

Начать опрос

Обновите index.js до финальной версии ниже:

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

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

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

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

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

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

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

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

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

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

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

Повторно разверните функцию:

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

Теперь вы можете полноценно протестировать приложение. Попробуйте выполнить команду /poll задайте вопрос и выберите несколько вариантов ответа. После отправки появится карточка опроса.

Проголосуйте и посмотрите, что произойдет.

Конечно, проводить опрос самому себе не так уж полезно, поэтому пригласите друзей или коллег попробовать это сделать!

6. Поздравления

Поздравляем! Вы успешно создали и развернули приложение Google Chat с помощью Cloud Functions. Хотя в этой лабораторной работе были рассмотрены многие основные концепции создания приложения, многое ещё предстоит изучить. Ознакомьтесь с ресурсами ниже и не забудьте привести свой проект в порядок, чтобы избежать дополнительных расходов.

Дополнительные мероприятия

Если вы хотите более подробно изучить платформу чата и это приложение, вот несколько вещей, которые вы можете попробовать самостоятельно:

  • Что происходит, когда вы упоминаете приложение через @? Попробуйте обновить приложение, чтобы улучшить поведение.
  • Сериализация состояния опроса в карточке подходит для небольших пространств, но имеет ограничения. Попробуйте выбрать более подходящий вариант.
  • Что делать, если автор захочет отредактировать опрос или прекратить приём новых голосов? Как реализовать эти функции?
  • Конечная точка приложения пока не защищена. Попробуйте добавить проверку, чтобы убедиться, что запросы поступают из Google Chat.

Это лишь несколько способов улучшить приложение. Развлекайтесь и дайте волю фантазии!

Уборка

Чтобы избежать списания средств с вашего аккаунта Google Cloud Platform за ресурсы, используемые в этом руководстве:

  • В консоли Cloud Console перейдите на страницу «Управление ресурсами» . В левом верхнем углу нажмите «Меню». значок меню > IAM и администрирование > Управление ресурсами .
  1. В списке проектов выберите свой проект и нажмите «Удалить» .
  2. В диалоговом окне введите идентификатор проекта, а затем нажмите кнопку «Завершить» , чтобы удалить проект.

Узнать больше

Дополнительную информацию о разработке чат-приложений см. в разделах:

Дополнительную информацию о разработке в Google Cloud Console см. здесь: