Google Chat Bot. Створюємо діалог

Від простого до складного, перед вами друга частина з серії статей про розробку ботів до Google чату.

Сьогодні я буду вас вчити як вести діалог з ботом.

Тож, в цій частині, я розповім вам, як створити діалог, та що треба робити, щоб можна було відправити до спейсу картку з картинками та текстом, наприклад, якщо вам треба відправити поздоровлення до дня народження. Буде більше коду, ніж в попередній частині та я буду намагатися пояснювати та додавати посилання на офіційну документацію.

Діалоги

Почнемо знов з додавання slash-команди до нашого боту, нагадую, що для цього нам треба перейти до налаштувань Google Chat API.

На відміну від попередніх команд які ми додавали тут вам треба поставити позначку ✅ Opens a dialog щоб далі працювати вже з діалогом.

Зміни до Code.gs теж не складні, нам треба лише піймати нову команду з ID 20:

/**
 * Responds to a MESSAGE event in Google Chat.
 *
 * @param {Object} event the event object from Google Chat
 */
function onMessage(event) {
  if (event.message.slashCommand) {
    // Checks for the presence of event.message.slashCommand
    // The ID for your slash command
    switch (event.message.slashCommand.commandId) {
      case 1:
        return slashHelp(event)
      case 10:
        return slashBender(event)
      case 11:
        return slashWhisky(event)
      case 20:
        return slashCard(event)
    }
  } else {
    // If the Chat app doesn't detect a slash command
    // ...
  }
}

Створими файл slashCard.gs з відповідною функцією:

/**
 * Opens a dialog in Google Chat.
 *
 * @param {Object} event the event object from Chat API.
 *
 * @return {object} open a Dialog in Google Chat.
 */
function slashCard (event) {
  return {
    'action_response': {
      'type': 'DIALOG',
      'dialog_action': {
        'dialog': {
          'body': {
            'sections': [
              {
                'header': 'Nice Card Builder',
                'collapsible': true,
                'uncollapsibleWidgetsCount': 2,
                'widgets': [
                  {
                    'textInput': {
                      'name': 'description',
                      'type': 'MULTIPLE_LINE',
                      'label': '📝 Description'
                    }
                  },
                  {
                    'textInput': {
                      'name': 'image_1',
                      'label': '1️⃣ Image URL',
                      'placeholderText': 'https://source.unsplash.com/featured/320x320'
                    }
                  },
                  {
                    'textInput': {
                      'name': 'image_2',
                      'label': '2️⃣'
                    }
                  },
                  {
                    'textInput': {
                      'name': 'image_3',
                      'label': '3️⃣'
                    }
                  }
                ]
              }
            ],
            'fixedFooter': {
              'primaryButton': {
                'icon': {
                  'materialIcon': {
                    'name': 'send'
                  }
                },
                'text': 'Send',
                'color': {
                  'red': 0,
                  'green': 0.5,
                  'blue': 1,
                  'alpha': 1
                },
                'onClick': {
                  'action': {
                    'function': 'receiveCard'
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Тут окремо слід зазначити 'type': 'DIALOG', це обов’язкове, а ось все що в нас у dialog_action – то ця конструкція нам вже знайома, цей JSON можна створити за допомоги Card Builder. Можете спробувати погратися та створити свій власний діалог.

Спробуємо команду /card, отримаємо наступний діалог:

Якщо спробуємо її відправити, то нічого не відбудеться! Що в цьому випадку робити? Дебажити?
Давайте спочатку подивимось чи не з’явилось в нас помилок у Apps Script, для цього перейдіть до розділу Executions, там вас буде чекати інформація про хід виконання скриптів:

Так, і чому воно в мене питає про функцію, про яку я нічого не знаю, та ніде не використовую, що це за onCardClick?
Це така службова функція яка потрібна нам щоб обробляти якраз події типу CARD_CLICKED, тож давайте створимо відповідну функцію обробник у файлі Code.gs:

/**
 * Responds to a CARD_CLICKED event in Google Chat.
 *
 * @param {Object} event the event object from Google Chat
 */
function onCardClick (event) {
  switch (event.common.invokedFunction) {
    case 'receiveCard':
      return receiveCard(event)
  }
}

Як бачите, я тут чекаю на ім’я функції яку треба визвати, та цю назву функції receiveCard я прописав у onClick екшені попередньої форми вище, у строчці 64, тож коли відкриється діалог, та ми клікнемо по кнопці Send то ми визвемо функцію receiveCard(event) яку нам ще треба написати. Давайте поступово, почнемо з пустої функції:

/**
 * @param {Object} event the event object from Google Chat
 */
function receiveCard (event) {
  console.log(event)
}

Коли ми використовуємо console.log, то результат ми зможемо знайти на сторінці Executions, давайте подивимось що зараз ми отримаємо коли спробуємо знову відправити форму (якщо у вас нічого не з’явилось відразу, попробуйте потицкати кнопку Refresh, поки не отримаєте результат):

{ 
  type: 'CARD_CLICKED',
  common: 
   { formInputs: 
      { image_3: [Object],
        image_2: [Object],
        image_5: [Object],
        image_1: [Object],
        image_4: [Object],
        description: [Object] },
     timeZone: { id: 'Europe/Kyiv', offset: 3600000 },
     userLocale: 'en-US',
     hostApp: 'CHAT',
     invokedFunction: 'receiveCard' },
  isDialogEvent: true,
  message: 
   { annotations: [ [Object] ],
     createTime: { seconds: 1707324122, nanos: 333183000 },
     retentionSettings: { state: 'PERMANENT' },
     thread: 
      { retentionSettings: [Object],
        name: 'spaces/.../threads/...' },
     name: 'spaces/.../messages/.......',
     messageHistoryState: 'HISTORY_ON',
     text: '/card',
     space: 
      { singleUserBotDm: true,
        spaceThreadingState: 'UNTHREADED_MESSAGES',
        name: 'spaces/...',
        spaceType: 'DIRECT_MESSAGE',
        type: 'DM',
        spaceHistoryState: 'HISTORY_ON' },
     formattedText: '/card',
     sender: 
      { displayName: 'Anton Shevchuk',
        avatarUrl: '...',
        type: 'HUMAN',
        domainId: '...',
        email: '...',
        name: 'users/...' },
     slashCommand: { commandId: 20 } },
  eventTime: { nanos: 716454000, seconds: 1707324126 },
  configCompleteRedirectUrl: '...',
  space: 
   { type: 'DM',
     spaceThreadingState: 'UNTHREADED_MESSAGES',
     spaceType: 'DIRECT_MESSAGE',
     name: 'spaces/...',
     singleUserBotDm: true,
     spaceHistoryState: 'HISTORY_ON' },
  dialogEventType: 'SUBMIT_DIALOG',
  user: 
   { email: '...',
     domainId: '...',
     type: 'HUMAN',
     name: 'users/...',
     displayName: 'Anton Shevchuk',
     avatarUrl: '...' },
  action: { actionMethodName: 'receiveCard' }
}

Ми отримуємо досить багато інформації, але нас цікавить саме інформація з полів, які ми дали заповнити користувачу, а за це відповідає event.common.formInputs, загляніть самостійно як виглядає формат цих даних, як на мене він трохи дивний, але най буде.
Щоб зручніше працювати з даними з форми я створив ось такий помічник, цу лістинг файлу FormInputHandler.gs:

class FormInputHandler {
  constructor(event) {
    // Check if formInputs exist in the event structure
    if (event.common && event.common.formInputs) {
      this.formInputs = event.common.formInputs;
      this.isValid = true; // Mark as valid if formInputs are found
    } else {
      // Handle the invalid case
      this.isValid = false;
      console.log('Invalid event: formInputs not found.');
      // Depending on your application, you might throw an error or handle this case differently
    }
  }

  // Method to check if the handler instance is valid
  isValidHandler() {
    return this.isValid;
  }

  // Retrieve a text input value by field name, adjusted for the specific structure
  getTextValue(fieldName) {
    if (this.isValid && this.formInputs[fieldName] && 
        this.formInputs[fieldName][''] && 
        this.formInputs[fieldName][''].stringInputs && 
        this.formInputs[fieldName][''].stringInputs.value) {
      return this.formInputs[fieldName][''].stringInputs.value[0];
    }
    return null;
  }

  // Additional methods to handle other types of inputs can be added here
  // ...
}

Ось приклад як ним можна користуватися:

/**
 * @param {Object} event the event object from Google Chat
 */
function receiveCard (event) {
  const formHandler = new FormInputHandler(event)

  if (!formHandler.isValidHandler()) {
    console.log('Invalid event: formInputs not found.')
    return
  }

  console.log(
    formHandler.getTextValue('description')
  )
}

Давайте спробуємо обробити запит з картки та створити щось цікавіше, ніж функціонал slashBender() (це те що ми реалізовували у попередньому уроці).

Валідація

Перевіремо, що ми отримали текст з поля Description, та якщо ні, то повернемо текст помилки:

  let description = formHandler.getTextValue('description')

  if (!description || description.trim() === '') {
    return {
      'actionResponse': {
        'type': 'DIALOG',
        'dialogAction': {
          'actionStatus': {
            'statusCode': 'INVALID_ARGUMENT',
            'userFacingMessage': 'You should write the description'
          }
        }
      }
    }
  }

На мій погляд виглядає досить крінжово, тому я знов таки вирішив трохи погратися в передчасну оптимізацію кода, перед вами файл Response.gs:

/**
 * The canonical error codes for gRPC APIs.
 * 
 * @link https://developers.google.com/chat/api/reference/rest/v1/spaces.messages#code
 */
// Not an error; returned on success.
// HTTP Mapping: 200 OK
const CODE_OK = 'OK'
// The client specified an invalid argument.
// INVALID_ARGUMENT indicates arguments that are problematic regardless of the state of the system (e.g., a malformed file name).
// HTTP Mapping: 400 Bad Request
const CODE_INVALID_ARGUMENT = 'INVALID_ARGUMENT'

/**
 * The type of Chat app response
 * 
 * @link https://developers.google.com/chat/api/reference/rest/v1/spaces.messages#responsetype
 */
// Post as a new message in the topic
const RESPONSE_TYPE_NEW_MESSAGE = 'NEW_MESSAGE'
// Update the message
const RESPONSE_TYPE_UPDATE_MESSAGE = 'UPDATE_MESSAGE'
// Update the cards on message of user
const RESPONSE_TYPE_UPDATE_USER_MESSAGE_CARDS = 'UPDATE_USER_MESSAGE_CARDS'
// Privately ask the user for additional authentication or configuration.
const RESPONSE_TYPE_REQUEST_CONFIG = 'REQUEST_CONFIG'
// Presents a dialog
const RESPONSE_TYPE_DIALOG = 'DIALOG'
// Widget text autocomplete options query
const RESPONSE_TYPE_UPDATE_WIDGET = 'UPDATE_WIDGET'

function actionResponse(type = RESPONSE_TYPE_DIALOG, statusCode = CODE_OK, message = '') {
  return {
    'actionResponse': {
      'type': type,
      'dialogAction': {
        'actionStatus': {
          'statusCode': statusCode,
          'userFacingMessage': message
        }
      }
    }
  }
}

/**
 * Card Helper
 *  - When all OK, status code
 */
function OK () {
  return actionResponse(RESPONSE_TYPE_DIALOG, CODE_OK, '👌')
}

/**
 * Card Helper
 *  - When argument is invalid
 */
function INVALID_ARGUMENT (message) {
  return actionResponse(RESPONSE_TYPE_DIALOG, CODE_INVALID_ARGUMENT, message)
}

Насправді ви ж розумієте, що це вже версія бота 2.0, тож всі граблі я вже зібрав у попередній версії 😅

Тож попередній приклад трохи змінемо, ось так він буде виглядати:

/**
 * @param {Object} event the event object from Google Chat
 */
function receiveCard (event) {
  const formHandler = new FormInputHandler(event)

  if (!formHandler.isValidHandler()) {
    console.log('Invalid event: formInputs not found.')
    return
  }

  let description = formHandler.getTextValue('description')

  if (!description || description.trim() === '') {
    return INVALID_ARGUMENT('You should write the description')
  }

  // ...
}

Якщо на цьому етапі спробувати відправити пусту форму, то отримаємо ось таке повідомлення:

Якщо дані заповнити, то отримаємо ось таке повідомлення, бо ще немає валідної відповіді від нашої функції:

Над цим і будемо працювати далі.

Ви нам форму, ми вам картку

Тож, якщо повернутися до нашого діалогу, то ви там побачите 4 поля, перше – це текстове поле на кілька рядків, та 3 поля в які, за моїм задумом, треба вставляти посилання на світлини. Давайте всю цю інформацію обробимо та сформуємо картку у відповідь:

/**
 * @param {Object} event the event object from Google Chat
 */
function receiveCard(event) {
  const formHandler = new FormInputHandler(event)

  if (!formHandler.isValidHandler()) {
    console.log('Invalid event: formInputs not found.')
    return
  }

  let description = formHandler.getTextValue('description')

  if (!description || description.trim() === '') {
    return INVALID_ARGUMENT('You should write the description')
  }

  let images = [
    formHandler.getTextValue('image_1'),
    formHandler.getTextValue('image_2'),
    formHandler.getTextValue('image_3')
  ]

  images = images.filter(n => n)

  let widgets = []

  while (images.length) {
    widgets.push({
      'image': {
        'imageUrl': images.shift(),
        'altText': ''
      }
    })
  }

  widgets.push(
    {
      'textParagraph': {
        'text': description
      }
    }
  )

  return {
    'actionResponse': {
      'type': RESPONSE_TYPE_NEW_MESSAGE,
    },
    'cardsV2': [
      {
        'cardId': 'niceCard',
        'card': {
          'sections': [
            {
              'collapsible': false,
              'widgets': widgets
            }
          ]
        }
      }
    ]
  }
}

У цьому вигляді ваш бот зможе вже створювати картку з однією світлиною:

Мені тут не дуже подобається оце створення віджетів у такий спосіб, мені краще програмний спосіб, але на жаль я не знайшов такої можливості тому створив файл Widgets.gs та почав наповнювати його функціоналом:

/**
 * @link https://developers.google.com/chat/api/reference/rest/v1/cards#TextParagraph_1
 */
function widgetTextParagraph(text) {
  return {
    'textParagraph': {
      'text': text
    }
  }
}

/**
 * @link https://developers.google.com/chat/api/reference/rest/v1/cards#image
 */
function widgetImage(url, alt = '') {
  return {
    'image': {
      'imageUrl': url,
      'altText': alt
    }
  }
}

Тепер виглядає краще:

  let widgets = []

  while (images.length) {
    widgets.push(
      widgetImage(images.shift())
    )
  }

  widgets.push(
    widgetTextParagraph(description)
  )

А потім я ще трохи розширив функціонал, додав підтримку до 5-ти картинок, та формую grid, щоб красиво це виводити. Щоб у коді не було безладу, то я додав ще один клас у файлі Grid.gs, але то буде вже домашнє завдання подивитися його код та зрозуміти що там відбувається.

Source Code

Код бота доступний на GitHub, реліз 2.0.0 відповідає коду з цієї статті.

Bender, реліз 2.0.0
Bender, білд 2.0.0

One thought on “Google Chat Bot. Створюємо діалог”

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.