Пример Бот-Магазина на 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 символов в сжатом виде получился бот, зато одной строкой :-) 

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

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