Пример Бот-Магазина на Google App Script

Тестирование работы простого Бот-Магазина на площадке Google App Script. Что из этого получилось? На сколько можно строить проекты в данном окружении? Какие проблемы работы приложения при хранении данных в Google Таблицах.

Бот получился простой: просмотр каталога, добавление в корзину товаров, оформление заказа. Наполнение тоже через бота. Пример пользовательской части можете посмотреть на тестовом боте.

* * *

Установка и тестирование бота

Если вам хочется потестировать или установить такого же бота себе, то исходники я также для вас приготовил. 

Исходники ShopBot.zip
41.1

В архиве лежат 3 файла: 

  1. Config.gs - В нем находятся основные настройки приложения и языковые настройки.
  2. BotShop.gs - Исходный код бота в одном файле с комментариями и описанием каждого класса.
  3. BotShop.min.gs - Исходный код бота только минимизированный.


Порядок действий при развертывании:

  1. Зайдите по адресу https://sheet.new/
  2. Скопируйте id таблицы из ее url
  3. Перейдите: Расширения > Apps Script
  4. Создайте файл Config.gs в него положите содержимое файла Config.gs из архива
  5. Создайте файл BotShop.gs в него положите содержимое файла BotShop.gs или BotShop.min.gs из архива
  6. Укажите основные настройки: admin_uid - ваш id в telegram, token - токен вашего бота, sheet - id таблицы.
  7. Сделайте развертывание
  8. Укажите в Основных настройках (файл Config.gs) webhook - это ссылка на приложение - получите на шаге №7
  9. В приложении перейдите на файл BotShop.gs, в инструментах выберите функцию initApp() и нажмите кнопку выполнить, в таблице создадутся листы для хранения данных.
  10. Теперь выберите функцию setWebHook() и нажмите выполнить.
  11. Перейдите в бот и нажмите Старт далее все по логике

Видео инструкция по установке


* * *

Теперь подробнее ...

Было не совсем уютно переписывать этого бота с php на javascript, тем более без использования базы данных. Все данные хранятся в таблицах в разметке Grid. По ходу реализации узнал, что у Google есть свой сервис по типу СУБД - называется BigQuery, используется язык запросов SQL, но тратить время и изучать не стал, так как на тот момент у меня уже был "запилен" класс по работе с таблицами - select, insert, update, delete ... 

Удивительно, но все работает!!! Только есть одно маленькое уточнение: работает, но очень медленно - ну потому что это работа с файлами. Выборку данных из таблиц сервис предлагает делать через фильтрацию.

Есть фильтры для типов STRING, NUMBER, DATE. Для каждого типа свои условия. Пришлось попотеть чтобы настроить под себя. 

Принцип поиска через фильтр:

  1. Получаешь файл таблиц по индентификатору
  2. В этом файле ищешь нужный тебе лист
  3. В этом листе определяешь диапазон применения фильтра
  4. Устанавливаешь фильтр
  5. Добавляешь в фильтр условия
  6. Получаешь все строки листа - нет у сервиса возможности в разметке Grid получить то что осталось на экране после применения фильтра
  7. Убираешь строки у которых есть метка что она скрыта фильтром
  8. Получаешь результат

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

Например: Пользователь переходит в раздел корзина, после запуска запроса нам нужно:

  1. Получить объект пользователя, поиск по одному параметру
  2. Сохранить данные пользователя если у него поменялись данные, ну и дату последнего посещения надо тоже обновить
  3. Получить все записи пользователя из листа Корзина, поиск по одному параметру
  4. Перебираем полученный результат корзины пользователя, нужно проверить есть ли такой товар в каталоге, поиск по двум параметрам - пользователь может вернуться в бот спустя год и товары, которые он добавлял ранее уже может не быть в каталоге
  5. Если такого товара не найдено, удаляем товар из корзины
  6. Считаем полную стоимость корзины
  7. Формируем данные для экрана
  8. Выводим в бот

Вроде бы простая и не сложная процедура, на СУБД это занимает доли секунды, но в этом случае, счет идет на секунды, и будет зависеть от количества товара в корзине пользователя.

Лично для себя сложил мнение, что хранить данные в таблицах с разметкой Grid не совсем удачное решение для бота, так как ждать ответа более 5 секунд - дорого для админа, пользователь просто закроет и отпишется от бота. 

Были случаи, когда после успешной обработки нажатия инлайн-кнопки на отправку метода notice() (нужно этим методом Телеграм уведомлять, что запрос пришел и обработан успешно, иначе он будет считать, что на сервере у вас ошибка), Телеграм отвечал, что слишком долго отвечаешь, уже не актуально.

Ближе к коду ...

Предлагаю рассмотреть интересные моменты, с которыми я столкнулся и какое на них нашел решение.

Начнем с того, что на каждый вид сущности у меня сделан отдельный класс, например class Order

/**
 * Класс Заказ
 */
class Order extends Model {
  /**
   * Настройки таблицы
   * @returns {{columns: string[], name: string}}
   */
  static table() {
    return {
      name: "Orders",
      columns: ['hash', 'uid', 'phone', 'delivery', 'address', 'pay', 'type', 'status', 'created_at'],
    }
  }

  /////
}

Для хранения данных объектов у каждого класса есть метод, который возвращает настройки static table() 

  1. name - название листа
  2. columns - навания столбцов

Когда мы запускаем функцию initApp(), то она как раз создает листы и заполняет столбцы получая данные из этих статичных методов в каждом классе.

Класс наследуется от class Model, вот в нем то и есть те самые интересные методы для нашего внимания. Весь код класса приводить не буду, это более 500 строк.

Например нам необходимо найти строку в листе Orders у которой в столбце hash (у меня это как альтернатива id) есть нужное нам значение, это тот функционал, с которым я столкнулся в самом начале. Много читал документации и понял, что для этого мне подойдет метод поиск по листу по типу Ctrl + F

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

  1. Определить таблицу
  2. Определить лист
  3. Получить диапазон столбца, он будет выглядеть примерно A1:A или C1:C
  4. Получаем строку в которой найдено нужно значение
  5. Получаем массив данных, где каждый элемент равен по порядку слева направо значению столбца строки
  6. Преобразуем этот массив в объект нужного нам класса, где свойство объекта заполнено соответствующим значением из массива

Ну как вам? Реализовывать было интересно, покажу примеры из кода класса

// Вызов поиска
let hashToSearch = "hfj4ff";
Order.find().findOneBy("hash", hashToSearch);

Так как все методы поиска в классе Model не статичные, и могут вызываться только от объекта имеющего эти методы, то я сделал статичный метод find(), который просто возвращает объект нужного нам класса, в примере будет объект класса Order.

/**
 * Получим новый объект
 */
static find() {
  return new this();
}

Сам метод findOneBy()

  /**
   * Поиск одного результата в таблице по значению
   * @param column
   * @param value
   * @returns {*}
   */
  findOneBy(column, value) {
    // получим index столбца
    let columnIdx = this.findIndex(column);
    // получим index строки
    let row_id = this.getRowIndex(columnIdx, value)
    // количество столбцов вправо
    let numCols = this.getColumns().length;
    // вернем результат (все результаты строки) если он есть
    return !isNull(row_id)
      ? this.getResult(this.getSheet().getRange(row_id, 1, 1, numCols).getValues())
      : null;
  }
  1. findIndex() - в нем мы получим индекс столбца относительно массива столбцов из настроек хранения table()
  2. getRowIndex() - в этом методе как раз и идет поиск по листу, возвращает или null или индекс строки
  3. getResult() - этот метод преобразовывает массив с данными строки в нужный нам объект

* * *

Поиск по нескольким параметрам с сортировкой

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

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

Вот пример, когда надо получить все товары определенной категории, с включенной видимостью и отсортированные по позиции

// значение родительского hash
let parent_ = "hfj4ff";
// настройки для поиска количества значений
let search_params = [
  ["parent", "string", "===", parent_],
  ["hide", "number", "===", 0],
];
// параметр сортировки
let search_sort = ["position", true];
// сделаем запрос
let items = Product.find().findAllByParams(search_params, search_sort);

Вот почти все методы, которые участвуют в этом действии - алгоритм ниже

  /**
   * Получаем все модели по параметрам
   * @param params
   * @param sort
   * @returns {*|Array}
   */
  findAllByParams(params = [], sort = null) {
    return this.getResultByParams(params, "all", sort);
  }

  /**
   * Получим по параметрам и с сортировкой
   * @param params
   * @param type
   * @param sort
   * @returns {*}
   */
  getResultByParams(params, type, sort) {
    // получаем результаты по заданным параметрам
    let result = this.findByParams(params);
    // проверяем
    if (result.length) {
      // если задана сортировка
      if (!isNull(sort)) {
        // настройки сортировки
        let [column, direction] = sort;
        // сортируем
        result.sort(function (a, b) {
          return a[column] > b[column]
            ? (direction ? 1 : -1)
            : (direction ? -1 : 1);
        });
      }
      // вернем результат
      return type === "one" ? result[0] : result;
    }
    // по умолчанию вернем пустой массив
    return [];
  }

  /**
   * Получаем результат по параметрам фильтрации
   * params = [
   *  {
   *    field: {
   *      column: "name_column"
   *      type: "number | string | date",
   *      action: "like | _like | like_ | not_like | === | !== | > | < | >= | <= | null | not_null | between | not_between"
   *      value: value | [values]
   *    }
   *  }
   * ]
   * @param params
   * @param returnAsObjects
   * @returns {*}
   */
  findByParams(params = [], returnAsObjects = true) {
    // проверяем
    if (params.length) {
      // получаем столбцы
      const columns = this.getColumns();
      // готовим массив фильтров
      let filters = {};
      // перебираем
      params.forEach(function (param) {
        // получим столбцы
        let [column, type, action, value] = param;
        // номер столбца
        let numColumn = columns.indexOf(column) + 1;
        // проверяем
        if (numColumn) {
          // получаем фильтр
          filters["_" + numColumn] = this.getFilterCriteria(column, type, action, value);
        }
      }.bind(this));
      // получаем таблицу
      const sheet = this.getSheet();
      // определяем диапазон
      const range = sheet.getRange(sheet.getDataRange().getA1Notation());
      // создаем фильтр
      const filter = range.createFilter();
      // применяем настройки фильтрации
      for (let key in filters) {
        filter.setColumnFilterCriteria(+key.slice(1), filters[key]);
      }
      // получаем результаты
      const result = this.getResultAfterFilter(returnAsObjects);
      // удалим фильтрацию
      filter.remove();
      // вернем результаты
      return result;
    }
    // вернем пустой массив
    return [];
  }

  /**
   * Вернем настройку фильтра
   * @param column
   * @param type
   * @param action
   * @param value
   * @returns {*}
   */
  getFilterCriteria(column, type, action, value) {
    /**
     * value - может быть
     *  строка ("tech")
     *  число (10)
     *  массив строк (["tech","business"])
     *  массив чисел ([10,20,30])
     *  диапазон из 2 чисел  (1, 25) только при between
     */
      // проверим action
    let isBetween = action.toLowerCase().includes("between");
    // проверим на массив
    let valueIsArray = Array.isArray(value);
    // подготовим массив для данных
    let values = [];
    // проверим
    if (isBetween) {
      // если это between - то заменим массив
      values = value;
    } else {
      // проверим на массив
      if (valueIsArray) {
        // получим диапазон столбца
        let range = this.getRange(this.findIndex(column));
        // рисуем формулу "=REGEXMATCH()"
        let formula = "REGEXMATCH(TO_TEXT(" + range + "); \"(" + value.join("|") + ")\")";
        // дополним
        values = action === "===" ? ["=" + formula] : ["=NOT(" + formula + ")"];
      } else {
        // добавим в массив
        values.push(value);
      }
    }
    // вернем настройку
    return SpreadsheetApp
      .newFilterCriteria()
      [this.getFilterCriteriaMethod(type, action, Array.isArray(value))](...values)
      .build();
  }
Мысли
Сейчас смотрю и понимаю, что можно все это оптимизировать еще очень сильно, мое самое любимое дело. Не знаю как остальные, но я сначала напишу логику так чтобы она стабильно работала, далее сижу и придумываю как можно сократить размер кода не в ущерб качества. Вот сейчас вижу что можно еще потрудиться над этим, но реально не вижу смысла так как использовать навряд ли буду, но если придется тогда и подсократим.

Алгоритм:

  1. Определяем массив с параметрами поиска, название столбца по которому применяем фильтр, тип значения по которому будем искать (строка, число, дата), действие (like | _like | like_ | not_like | === | !== | > | < | >= | <= | null | not_null | between | not_between), искомое значение (строка, массив строк)
  2. Определяем сортировку: свойство по которому будем сортировать и направление сортировки (по возрастанию или по убыванию)
  3. Передаем эти параметры в метод findAllByParams()
  4. так как есть еще разные варианты поиска в котором используются одни и те же методы, то по цепочке вызовов настройки поиска с дополнительными параметрами попадают в метод findByParams()
  5. В этом методе мы создаем массив из методов фильтрации getFilterCriteria() и после создания фильтра применяем его к листу дополняя методами фильтрации
  6. После получаем все отфильтрованные строки
  7. Создаем из них объекты
  8. Сортируем и возвращаем результат

Много интересного происходит в методе getFilterCriteria(), там есть запара как передать правильные значения в фильтр, так как эти значения могут быть: просто значение (строка, число, дата), массив (строк, чисел, дат), 2 значения для методов серии  between (start, end) ну и на конец формула с регуляркой.

Долго думал и решил очень интересно: используя оператор «остаточные параметры» – троеточие ("..."), то есть я специально все упаковывал в массив, и разворачивал этот массив. Сложно объяснить на пальцах, но вещь очень крутая.

* * *

Наверное на этом все, текст получился сумбурным, но ведь когда пишешь код, всегда кажется: - Ща как запилю статью интересную!

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

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

Ну и так далее....

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

Более 55 000 символов в сжатом виде получился бот, зато одной строкой :-) 

Комментарии приветствуются ...

19 комментариев
Авторизуйтесь через Telegram, чтобы оставить комментарий.
Откройте по ссылке или QR бот @iMakeBot, нажмите кнопку Старт/Start.
Следуйте инструкциям бота.

  • Sertemon [1 год назад]

    Выполнил весь порядок действий при развертывании, но бот не работает.

  • iMakeBots [1 год назад → Sertemon]

    Не реагирует на старт? 
    Есть какие-то записи в таблице?
    Права выданы на приложение? 
    В логах есть какие-то записи?
    Вебхук нормально установился? 

  • Sertemon [1 год назад → iMakeBots]

    Не реагирует на старт.
    Запись в таблице отсутствует.
    Права на использование приложения есть.
    Вебхук установлен нормально.

    Вот ссылки на скриншоты: 
    https://tlgur.com/d/4rqDvBwg
    https://tlgur.com/d/GJvAoexG
    https://tlgur.com/d/4Rmqxw,
    https://tlgur.com/d/89BJP9bGeg

  • iMakeBots [1 год назад → Sertemon]

    Вот на этом скрине https://tlgur.com/d/GJvAoexG, не видно листов которые должны быть: Logs, Users, Categories, Products, Medias, Baskets, Orders, OrderItems, Pages

    Они есть? 

  • Sertemon [1 год назад → iMakeBots]

    Их нет, сейчас создам и буду перепроверять

  • iMakeBots [1 год назад → Sertemon]

    Они должны автоматически создаться при выполнении initApp()

  • Sertemon [1 год назад → iMakeBots]

    Да, листы были созданы автоматически при выполнении выполнении initApp()

  • Sertemon [1 год назад → iMakeBots]

    После замены скрипта BotShop вместо BotShop.min, в журнале логов ранее во время выполнения функции doPost() был сбой но теперь его нет, но бот всё ровно не отвечает вот скрин: 

    https://tlgur.com/d/GXjxdKl4

  • Sertemon [1 год назад → iMakeBots]

    Ура, меня всё получилось теперь скрипт и бот работает как надо, вот ссылка на скриншот:

    https://tlgur.com/d/GdXJqpvG .

    Всё заработало после выявления в скрипте ошибки, а именно в функции doPost ()  я убрал проверку "if(request.parameter.token === config.token)", рекомендую исправить это в вашем zip архиве.

    P.S. 

    Спасибо за классный скрипт бота, вы очень старательный и большой молодец.

  • iMakeBots [1 год назад → Sertemon]

    Спасибо.

    Это не совсем ошибка, то есть это совсем не ошибка))) это специально зашитая логика.

    В методе setWehook()мы токен зашиваем в вебхук, и это условие делает строгую проверку на соответствие токена из настроек с токеном из параметра вебхука url=" + config.webhook + "?token=" + config.token

    /**
     * Устанавливаем Вебхук
     */
    function setWebHook() {
      let response = UrlFetchApp.fetch(config.apiUrl + config.token + "/setWebHook?url=" + config.webhook + "?token=" + config.token);
      console.log(response.getContentText());
    }

    Почему для вас это не сработало - не понятно.

    Исходя из вашего первого скрина https://tlgur.com/d/4rqDvBwg, в url вашего вебхука не видно этого параметра. И условие не проходило.

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

    В любом случае спасибо за упорство)) будем наблюдать. 

  • Sertemon [1 год назад → iMakeBots]

    Это не сработало, по той причине что этот бот и скрипт уже были ранее взаимосвязаны, именно по этому метод setWehook() не использовался, видимо по этому.

    Кстати я так восхищаюсь написанным вами скриптом и по этому рекомендую вам развивать данную тему и возможно вам будет любопытно где же взять вдохновения для продолжения развития?

    Это можно найти в телеграм конструкторе ботов: https://t.me/QNеxtBot .

    В нём есть масса интересных модулей, функций, триггеров, конструкций, и реакций; которые можно повторить. 

    Если будут вопросы лично ко мне, то вы можете написать мне в т.г. @sertemon

  • -_- [1 год назад → Sertemon]

    Прошу прошение эта ссылка нерабочая 

  • Д В [1 год назад → iMakeBots]

      Здравствуйте! Очень восхищён Вашими проектами! Спасибо Вам огромное! 

    От программирования далёк, но согласно инструкции установил) 

    Помогите разобраться почему не отменяется запись? И почему не заходит в админку с родительского профиля? Пишет что доступ запрещен. 

    И как вручную удалить запись? Удалял из календаря но через бот она отображается как действующая

    https://drive.google.com/file/d/1MKwolFwOuXAx_ko0xjedGxgrjxSWc_7v/view?usp=drivesdk

    Заранее БЛАГОДАРЮ! 

  • iMakeBots [1 год назад → Д В]

    Вы про бот запись на услугу? )) Просто эта статья про бот-магазин, ну да ладно )

    Из календаря (гугл) записи удаляются или через бота (идет удаление из таблицы и календаря), или в ручную: из календаря + удаление строки в таблице на листе Notes 

    По поводу админки, как раз в комментах (в нужной статье) у нас идет дискуссия по этому поводу.

  • Dmitriy Bryantsev [1 год назад]

    Спасибо вам за ваши примеры. Скажите, а как отправить фото пользователю с помощью бота на google-app-script?

  • iMakeBots [1 год назад → Dmitriy Bryantsev]

    Используйте метод sendPhoto

    function sendPhoto(chat_id, file_id, text = null) {
      // подготовим набор данных
      let payload = {
        method: "sendPhoto",
        chat_id: String(chat_id),
        photo: file_id,
        parse_mode: "HTML"
      };
      if(text != null) {
       payload.caption = text;
      }
      // вернем результат отправки
      return query(payload);
    }
    
    /**
     * Направляем запрос в Телеграм
     */
    function query(payload) {
      let data = {
        method: "post",
        payload: payload
      };
      return JSON.parse(UrlFetchApp.fetch(config.apiUrl + config.token + "/", data).getContentText());
    }
  • Dmitriy Bryantsev [1 год назад → iMakeBots]

    Спасибо за ответ! Не ожидал такой оперативности. Для тех, кто будет использовать эту функцию - file_id - должен содержать http-ссылку на картинку.

    Есть ли способ отправить картинку если она у меня на внутреннем ресурсе лежит? Т.е. доступ у меня к ней есть, и я могу, например Blob получить, но извне картинка недоступна.

  • iMakeBots [1 год назад → Dmitriy Bryantsev]

    Для отправки blob, можно отправить используя мультипарт-форм-дата

    file_id можно урл а можно из телеграма файл_айди

  • Dmitriy Bryantsev [1 год назад → iMakeBots]

    Задал вопрос про мультипарт-форм-дата на вашем форуме.