Перейти к основному контенту

Web App форма c использованием keyboard-кнопки (только для Telegram)

Инструкция для разработчика бота.

Данный вид формы работает только в Telegram.

Ключевая особенность: форма открывается в всплывающем окне на котором расположен WebView (встроенный браузер). Для отправки данных в Metabot API не требуется дополнительный backend.

Актуальный исходный код веб-формы реализующий все три вида форм смотрите по ссылке: go-to-the-mars.html

Пример работы веб-формы приведенной выше смотрите в Telegram боте https://t.me/metabot_test_form_bot

Отличие данного варианта от универсальной формы в виде ссылки, в том, что форма работает как Web App, т.е. это специальный режиме браузера встроенного в Telegram. Этот режим гарантирует возврат в бота после заполнения формы, а также не позволит открыть ссылку во внешнем браузере (а также включает дополнительный функционал для взаимодействия веб-страницы с ботом). В обычном же режиме для универсальной формы, нет гарантий, что когда веб-страница будет закрыта, пользователя перекинет в бота.

Краткое описание принципа работы бота:

1.

     В бот отправляется keyboard-кнопка (в формате Telegram Web App). При нажатии на кнопку открывается всплывающее окно, в которое встроен Web View, пользователь не может открыть эту ссылку во внешнем браузере, а также просмотреть исходный код формы (если только не откроет сам Telegram в браузере).

    2.

     После заполнения формы и нажатия на кнопку Отправить данные, все данные будут отправлены в Metabot, в команду JavaSctript Callback, Internal API endpoint в данном случае не требуется. Для понимания смотрите исходный код веб-формы.

    3.

    Данные пришедшие с формы доступны в команде JavaSctript Callback в виде обычного вебхука. Необходимо сохранить их в таблице.

    3. 

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

    Принцип работы, по шагам:

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

    • ботБот отправляет эту ссылку в виде inline-кнопки в Telegram-мессенджермессенджер.

    • пользовательПользователь бота нажимает на кнопку, открывается Web App с формой (т.е. пользователь остается в Telegram, в мессенджере открывается встроенный браузер).

    • пользовательПользователь бота заполняет форму и нажимает кнопку для отправки формыформы.

    • формаФорма отправляет данные на backend сайта, где размещена формаформа.
    • backendBackend сайта отправляет данные формы в API Metabot, в данные должен быть включен уникальный хэш лидалида.
    • Metabot принимает и сохраняет данные заполненной формыформы. 
    • еслиЕсли все ок, то страница с формой в браузере должна быть закрыта, это делается с помощью простого JS кода (подробности рассматриваются на отдельной странице документации с описанием создания HTML-формы).


    1.

    Таблицы для работы с формами

    Чтобы рассматриваемый пример бота с формами работал, вам необходимо создать две кастомные таблицы.

    1.1.

    Таблица: Хэш-коды лидов

    Таблица необходима для хранения соответствий между лидом и хэшом для лидалида.

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

    Структура таблицы:

    image.pngimage.png


    1.2.

    Таблица: Данные форм

    Таблица необходима для хранения данных заполненных формформ.

    Данная таблица реализована как универсальная, все данные пришедшие с формы сохраняются в текстовом поле в виде JSON, поэтому она может работать с любой формой, но работать с строкой в виде JSON в JS неудобно, тк для доступа к данным приходится выполнять JSON.parse(), вы можете нормализовать данные, реализуя для каждой формы бота отдельную таблицу с необходимыми полями для хранения, например такими как: Имя, Фамилия, Адрес и т.д., чтобы в дальнейшем было проще работать с данными и выполнять поиск по таблице. Также, в вашем случае таблица для хранения данных может и не потребоваться, если всплывающая форма очень простая и предназначена, например, для заполнения одного поля, например, адреса с авто комплитом от Dadata или для выбора выпадающего списка или даты в календаре. Т.е. форма может реализовывать даже один компонент для заполнения которого необходим HTML + JS.

    Структура таблицы:

    PKCu1KBiYK3GrQC6-image.png

    image.png

    Чтобы посмотреть исходный код внутреннего API, сначала перейдите в бота по ссылке на любой из скриптов, например по ссылке:

    https://app.metabot24.com/menubuilder/36182скриптов.

    Нажмите в предупреждающем сообщении на ссылку для смены активного бота, а затем перейдите на страницу c внутренним API в настройках бота или по ссылке:

    https://app.metabot24.com/api-internal-endpointsссылке.

    Если у вас нет доступа к указанному скрипту, то запросите шаблон бота в технической поддерже Metabot.


    2.

    Скрипт для формирования ссылки и отправки кнопки в мессенджер

    Пример скрипта:

    https://app.metabot24.com/menubuilder/36192Пример скрипта.

    Скрипт отправки ссылки состоит из единственной команды бота:

      2.1. Выполнить JavaScript Callback:

      image.png

      const menuHelper = require('Common.TelegramComponents.MenuHelper')
      let md5 = require('Common.Utils.Md5')
      
      // ---------------------------------------------------------------------------------------------------
      // Инициализация компонента
      if (menuHelper.isFirstImmediateCall()) {
        // !!! ИНИЦИАЛИЗАЦИЯ !!!
      
        // Скрываем предыдущее меню c кнопкой формы
        menuHelper.removeInlineKeyboard()
      
        // Сбрасываем ID последнего сообщения
        menuHelper.clearLastTelegramMessageId()
        
        // --------------------------
        //ГЕНЕРИРУМ ССЫЛКУ С ХЭШЕМ
        let expireAt = new Date()
        expireAt.setSeconds(expireAt.getSeconds() + 300)
      
        let hashItems = table.find('lead_form_hash', [], [
          ['form', '=', 'mars'],
          ['lead_id', '=', leadId],
        ])
      
        // Todo: Можно вынести в сниппет
        let leadHash
        if (hashItems.length > 0) {
          leadHash = hashItems[0].hash
        } else {
          const salt = 'YourSuperSecretString' + (new Date()).getTime()
          leadHash = md5(salt + leadId)
      
          table.createItem('lead_form_hash', {
            'form': 'mars',
            'lead_id': leadId,
            'hash': leadHash,
            'expireAt': expireAt
          })
        }
      
        // Вы можете вынести эту ссылку в атрибуты бота
        let formUrl = 'https://app.metabot24.com/testWidgets/go-to-the-mars.html?mode=tg_keyboard&q=' + leadHash
        // ----------------------------
      
        // Отправляем кнопку с формой при инициализации
        let message = 'Для продолжения, пожалуйста заполните форму'
        let buttons = [
          [
            {
              "text": "Заполнить форму",
              "web_app": {
                "url": formUrl
              }
            }
          ]
        ]
        let apiAdditionalParams = {
          "reply_markup": {
            "keyboard": buttons, 
            "resize_keyboard": true // Для того чтобы кнопка не была слишком большой по высоте
          }
        }
        menuHelper.sendMessage(message, null, null, apiAdditionalParams)
        
        return false
      }
      // ---------------------------------------------------------------------------------------------------
      
      // ---------------------------------------------------------------------------------------------------
      // ЛОГИКА КОМПОНЕНТА
      // ---------------------------------------------------------------------------------------------------
      
      // ---------------------------------------------------------------------------------------------------
      // Обработка нажатых кнопок или входящего текста
      
      let webhook = bot.getWebhookPayload() 
      let webAppData = null
      let data = null
      let rawJsonData = null
      
      if (typeof webhook.message == 'object') {
        if (typeof webhook.message.web_app_data == 'object') {
          webAppData = webhook.message.web_app_data
          
          //Example:
          //"web_app_data": {
          //  "button_text": "Заполнить форму",
          //    "data": "{...}"
          //}
          
          if (typeof webAppData.data === 'string') {
            rawJsonData = webAppData.data
            data = JSON.parse(rawJsonData)
            if (typeof data !== 'object') {
              rawJsonData = null
              data = null
            }
          }
        }
      }
      
      // ---------------------------------
      if (data !== null && typeof data === 'object' && typeof data.q === 'string') {
        // СОХРАНЕНИЕ ДАННЫХ
        // КОД ИДЕНТИЧНЫЙ API INTERNAL ENDPOINT
        // TODO: МОЖНО ВЫНЕСТИ В СНИППЕТ
        
        // Доп защита
        // Все равно проверяем хэш (на случай чтобы не взломали)
        // А также чтобы почистить формы ожидающие заполнения по лиду (хэши по лиду)
        // Можно не выполнять эту проверку здесь
        // Todo: Можно вынести в сниппет
        let hashItems = table.find('lead_form_hash', [], [
          ['form', '=', 'mars'],
          ['hash', '=', data.q],
        ])
      
        let found = 0
      
        if (hashItems.length > 0) {
          formLeadId = hashItems[0].lead_id
          hashItems.forEach(hashItem => hashItem.delete())
        }
      
        // Доп защита
        // Проверяем что к нам пришла форма принадлежащая лиду
        // Можно не выполнять эту проверку здесь
        if (formLeadId === leadId) {
          // Удаляем предыдущие ответы
          // Todo: Можно вынести в сниппет
          oldResults = table.find('form_results', [], [
            ['form', '=', 'mars'],
            ['lead_id', '=', formLeadId],
          ])
          if (oldResults.length > 0) {
            oldResults.forEach(oldResult => oldResult.delete())
          }
      
          // Предварительно сохраняем данные в таблице
          table.createItem('form_results', {
            "form": "mars",
            "lead_id": formLeadId,
            "data": rawJsonData
          })
      
          // Удаляем кнопку калькулятора, если она есть !
          // Внимание! Удалять можно только с отправкой сообщения!
          menuHelper.removeReplyKeyboardWithMessage('Спасибо, данные получены!')
          
          //bot.scheduleScriptByCode('after_submit', formLeadId)
          
          // Выходим из цикла и запускаем скрипт уведомления о принятой форме
          return {
            "break": true,
            "run_script_by_code": "after_submit"
          }
        } else {
          // Можно вывести сообщение - что чтото прилетело из того что не ожидали 
          // или выслать письмо администратору
          // ...
        }
      }
      // ---------------------------------
      
      // Todo: Здесь можно повторно отправить кнопку, тк она скрывается с экрана если отправить сообщение в бота
      bot.sendText('Пожалуйста, заполните форму, для этого нажмите на кнопку в нижней части мессенджера,'
                   + ' после этого откроется форма, которую вам необходимо заполнить'
                   + ' и нажать кнопку на форме "Отправить заявку"')
      
      return false
      
      // ---------------------------------------------------------------------------------------------------


      Обратите внимание на строки с кодом запуска скрипта, после сохранения данных с формы:

          // Выходим из цикла и запускаем скрипт уведомления о принятой форме
          return {
            "break": true,
            "run_script_by_code": "after_submit"
          }

      Этот код может быть заменен на "return true" для выхода из цикла и на команду бота - "Выполнить скрипт", которую в таком случае необходимо разместить после команды JavaScript Callback. Подробнее смотрите в описании команды Выполнить JavaScript Callback.


      4.

      Скрипт выполняемый после сохранения данных 

      Данный скрипт запускается из JavaScript Callback, код которой приведен выше.

      Данный скрипт нужен чтобы пользователь продолжил диалог с ботом, а также демонстрирует как использовать данные сохраненные в таблицу результатов и вывести их в виде текста.

      Пример скрипта:

      https://app.metabot24.com/menubuilder/36180скрипта.

      Скрипт отправки ссылки состоит из следующих команд бота и пунктов меню:

        Команда 4.1. Команда: Выполнить JavaScript:
        const menuHelper = require('Common.TelegramComponents.MenuHelper')
        
        // --------------------------------------------------------------
        // Удаляем кнопку с формой для Inline формы, если она выведена
        
        if (menuHelper.hasLastTelegramMessageId()) {
          // Удаляем кнопку с ссылкой на форму
          menuHelper.removeInlineKeyboard()
        
          // Сбрасываем ID последнего сообщения
          menuHelper.clearLastTelegramMessageId()
        }
        // --------------------------------------------------------------
        
        // --------------------------------------------------------------
        // Для Inline кнопки c формой
        // Удаляем кнопку с формой , если она выведена
        // Удаляем здесь - тк после заполнения формы в JS-Callback мы уже не попадаем
        // Тк прием данных с формы осуществляет через Internal Endpoiint
        // Для keyboard кнопки ссылку на форму с кнопкой здесь удалять не нужно, 
        // тк для keyboard кнопки мы возвращаемся в JS-Callbak после заполнении формы
        if (menuHelper.hasLastTelegramMessageId()) {
          // Удаляем кнопку
          menuHelper.removeInlineKeyboard()
        
          // Сбрасываем ID последнего сообщения
          menuHelper.clearLastTelegramMessageId()
        }
        // --------------------------------------------------------------
        
        // Если получаем данные напрямую, без таблицы form_results
        //let data = request.json
          
        // Если получаем данные из таблицы form_results
        // Todo: Можно вынести в сниппет
        let data = table.find('form_results', [], [
          ['form', '=', 'mars'],
          ['lead_id', '=', leadId]
        ])
        
        if (data.length > 0 && typeof(data[0].data) === 'string' && data[0].data.length > 0) {
          data = JSON.parse(data[0].data)
        } else {
          data = {}
          bot.sendText('Ошибка! Данные не найдены')
        }
        
        // Для определения какую кнопку показывать в меню ниже
        // См условие аунктов меню текущего скрипта
        memory.setAttr('mode', '')
        if (typeof(data.mode) === 'string' && data.mode.length > 0) {
          memory.setAttr('mode', data.mode)
        }
        
        
        /*
        // Сообщение о результате отработки формы, отправляется в бота
        // Но проблема что система распознает сообщение как вебхук от лида
        // Поэтому использовать можно только в JS-Callback и игнорировать вебхук вручную
        // Или через регулярку маршрута, запуская отдельны скрипт
        //
        // Данное сообщение также автоматом закрывает web-view
        // Но пробелм с закрытием нет, это выполняется в JS итак с помощью
        // кода window.Telegram.WebApp.sendData(JSON.stringify(formData));
        // или при выполнении window.Telegram.WebApp.close()
        //
        // Обычно используется для игры в web_view для вывода очков после победы или проигрыша
        if (data.tg_query_id && data.tg_query_id.length > 0) {
          //https://core.telegram.org/bots/api#sentwebappmessage
          //https://core.telegram.org/bots/api#inlinequeryresultarticle
          bot.sendPayload('answerWebAppQuery', {
            "web_app_query_id": data.tg_query_id,
            "result": {
              "type": "article", 
              "id": "1", // Unique identifier for this result, 1-64 Bytes
              "title": "Форма заполнена", 
              //"description": "Description", 
              "input_message_content": {"message_text": "Спасибо! Данные сохранены!"}
            }
          })
        }
        */
        
        memory.setJsonAttr('data', data)
        memory.setAttr('is_qualified', data.is_qualified ? 'Да' : 'Нет')
        memory.setAttr('has_experience', data.has_experience ? 'Да' : 'Нет')


        Команда 4.2. Команда: Отправить текст:
        Ваши данные:
        Имя: {{ &$$data.name }}
        Email: {{ &$$data.email }}
        Возраст: {{ &$$data.age }}
        Профессия: {{ &$$data.specialization}}
        Ваш адрес проживания: {{ &$$data.address}}
        Прошел курсы в Центре подготовки космонавтов: {{ &$is_qualified }}
        Я уже летал в космос (имею опыт): {{ &$has_experience}}
        
        Все данные в виде JSON:
        {{ &$$data }}


        Команда 4.3. Команда: Отправить текст:
        Все верно?


        4.4. Пункты меню данного скрипта, с условиями

        условиями:

        image.png

        Условия кнопок необходимы для вызова скрипта соответствующего текущему формату формыформы.

        Условие для вывода универсальной формы в виде ссылки:

        let mode = memory.getAttr('mode')
        if (mode === 'tg_inline') {
          return true
        }

        Условие для вывода формы в Web App при нажатии на inline-кнопку:

        let mode = memory.getAttr('mode')
        if (mode !== 'tg_inline' && mode !== 'tg_keyboard') {
          return true
        }

        Условие для вывода формы в Web App при нажатии на keyboard-кнопку:

        let mode = memory.getAttr('mode')
        if (mode === 'tg_keyboard') {
          return true
        }