Бот обратной связи без хостинга: Google Apps Script + Telegram Bot API

Очень интересный вариант окружения для разработки бота. Хостинг, домен и SSL сертификат не требуется. Организация хранения данных пользователей в Google Sheets (Google Таблицы). Онлайн редактор для разработки.

В статье я опишу свое знакомство с новым для себя инструментом, подробно остановлюсь на коде бота, оставлю ссылку на полную версию бота и инструкцию как развернуть бота на Google Apps Script. Весь код на JavaScript с элементами Google API

В среднем 7 минут на развертывание Бота Обратной связи
Я специально просил своих знакомых, не имеющих никакого опыта в программировании, попробовать развернуть этого бота по инструкции. В среднем требовалось 7 минут. Просто берешь код и вперед... Практически все оставили бота для своих нужд, так как он не требует никаких затрат на хостинг, домен и ssl-сертификат, нужна только учетная запись в Google.

Давно уже слышал про Google Apps Script, но все время откладывал возможность потестить для себя его функционал. Появилось немного свободного времени, на улице пасмурно и ко всему этому еще попалось интересное видео на эту тему - так сказать звезды сошлись. Ну, что же надо попробовать. И ведь попробовал. И был в шоке, от того как было интересно. Насколько затянуло, что даже во сне продолжал скрипты оформлять.

Как всегда, нового бота для тестирования придумать не смог, взял из уже имеющихся, конечно же этим ботом стал "Бот Обратной Связи". Функционал его понятен, и надо было уже показать пример как обходить настройку, когда пользователь запрещает пересылать сообщения. Очень много обращений по данному запрету. 

* * *

Чем интересен Google Apps Script для владельца Telegram Bot:
  1. Размещение скрипта бота в Google, хостинг не требуется
  2. Хранение данных в Google Sheets (Google Таблицы)
  3. Отсутствует необходимость в доменном имени и соответственно в SSL сертификате
  4. Наличие online-редактора кода с подсветкой
  5. Есть встроенная система контроля версий (свой аналог Git)
  6. Разграничение доступа
  7. И многое чего еще я не пробовал ....

В ходе разработки я конечно же столкнулся с некоторыми непонятными для меня ситуациями*, такими как отсутствия поддержки Class Import/Export, либо я не до конца разобрался, но разбить проект на несколько файлов (отдельно для каждого класса) у меня не получилось. Странно, ведь как утверждается, работает все это дело на движке V8.

_____
>>> * Позже узнал как можно организовать архитектуру проекта, но про это в следующих статьях <<<

* * *

Кратко о функционале разрабатываемого бота

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

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

* * *

Переходим к главному, к разработке нашего бота

Принцип работы в Google Apps Script заключается в создании проекта, размещении в нем скриптов, после развертывания проекта вы получите ссылку на "точку входа", на которую и будет приходить все данные от Телеграм через настроенный WeHook. Так называемая точка входа может принимать как GET так и POST, для получения данных и их обработки нужно создать почти одноименные функции в основном файле doGet() и doPost(). Про наш doPost() немного ниже.

А начнем мы, пожалуй, с описания настроек, их оказалось чуть больше чем обычно.

Расшифровка настроек:

  1. sheet - id Google Таблицы, где мы будем хранить данные бота
  2. webUrl - адрес вашего приложения Google Apps Script
  3. token - токен вашего бота
  4. userNameBot - username вашего бота
  5. apiUrl - адрес Telegram API
  6. botAdmin - ваш личный id в Telegram
  7. langParams - языковые настройки
  8. linkCommands - массив команд бота и их обработчики
  9. db - настройки хранения данных, названия листов в таблице и порядковые номера столбцов в которых хранятся свойства сущностей
/**
 * Настройки Бота
 */
const config = {
  sheet: "1HbWBlyXMj..........PrVjzQqc_QF......bS_S5fY",
  webUrl: "https://script.google.com/macros/s/AKfycbz0muZsFbS........2wONwoCg102OV5g/exec",
  token: "540.....83:AAG4.......0DgsEo3QFx.....7F3o",
  userNameBot: "......FeedBackBot",
  apiUrl: "https://api.telegram.org/bot",
  botAdmin: 00000000,
  langParams: {
    ru: {
      admin: {
        hello: "Начинаем ждать сообщений от пользователей",
        answer: {
          self: "Ответ на свое сообщение",
          bot: "Ответ на сообщение бота",
          button: {
            reply: "Надо поставить сообщение пользователя в ответ.",
          },
          error: {
            send: "Не удалось отправить сообщение пользователю"
          }
        }
      },
      user: {
        hello: "Приветствую Вас, {name}.\nЯ очень жду вашего сообщения.\n------\nСпасибо."
      }
    }
  },
  linkCommands: [
    {
      template: /^\/start$/,
      method: 'start'
    }
  ],
  db: {
    users: {
      table: "Users",
      uid: 1,
      name: 2,
      userName: 3,
      lang: 4,
      created_at: 5,
      updated_at: 6
    }
  }
}

* * *

Вспомогательные функции

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

function getMe() {
  let response = UrlFetchApp.fetch(config.apiUrl + config.token + "/getMe");
  console.log(response.getContentText());
}

function getWebHookInfo() {
  let response = UrlFetchApp.fetch(config.apiUrl + config.token + "/getWebHookInfo");
  console.log(response.getContentText());
}

function setWebHook() {
  let response = UrlFetchApp.fetch(config.apiUrl + config.token + "/setWebHook?url=" + config.webUrl);
  console.log(response.getContentText());
}

Исходя из названий функций, можно определить, что они делают запрос в Telegram Bot API и получают данные как о самом боте, так и о его webHook, и еще одна функция это для установки webHook для бота, ей мы воспользуемся один раз, сразу после первого развертывания проекта - у нас для этого уже появится webUrl.

Метод UrlFetchApp.fetch() - это метод, который идет уже под капотом Google Apps Script, он делает запрос по указанному вами адресу. Через него можно направлять и GET и POST запросы. В наших вспомогательных функциях мы передаем GET запросы.

* * *

Наша точка входа doPost()

Функция doPost() по умолчанию обрабатывает POST запросы к нашему приложению, так как Telegram Bot API при использовании WebHook направляет данные через POST, то это как раз наш вариант:

/**
 * Получаем данные от Телеграм
 */
function doPost(request) {  
  // получаем данные
  let update = JSON.parse(request.postData.contents);
  // направляем данные в объект WebHook
  new WebHook(update);
}

То, что нас интересует лежит в объекте postData.contents, мы их сразу обрабатываем, далее создаем объект класса WebHook и передаем в него объект с данными от Telegram преобразованный из "сырой" строки в JavaScript объект.

* * *

Class Helper

В нашем помощнике есть всего 2 статичных метода: isSet() - проверяет на существование и isNull() - проверяет на null, простые но часто используемые конструкции.

/**
 * Класс Helper
 */
class Helper {
  /**
   * Проверяем на существование
   */
  static isSet(variable) {
    return typeof variable !== "undefined";
  }
  /**
   * Проверяем на null
   */
  static isNull(variable) {
    return variable === null;
  }
}

* * *

Class WebHook

Основной класс бота, в нем у нас лежит все необходимое окружение:

  1. Объект пользователя
  2. Объект языковых настроек
  3. Объект класса Bot с необходимыми методами для работы с Telegram Bot API

Методы класса:

  1. constructor() - определяем необходимые объекты
  2. route() - метод, который определяет исходя из пришедших данных куда их направлять для обработки. В основном это проверка на текстовые команды, в нашем наборе в настройках указана только одна команда /start, но при необходимости можно добавить, а также проверка кто написал: пользователь или админ, от этого зависит каким методом будут копироваться сообщения (sendCopyToAdmin() или copyMessage()).
  3. checkCommand() - проверяет на совпадение с шаблоном команды, в случае совпадения передает название метода для обработки
  4. isAdmin() - проверяет пользователя, является ли он владельцем бота исходя из настроек
  5. isBot() - дает информацию сообщение, на которое идет ответ от администратора, принадлежит боту или пользователю
  6. start() - метод обработки команды /start
  7. prepareMethod() - метод который преобразует строку типа video_note в VideoNote
  8. sendCopyToAdmin() - вот он то самый метод, который в дуэте с методом route() позволяет обойти настройку Telegram, которая запрещает пересылать сообщения.
Для справки
В предыдущих версиях бота я использовал метод forwardMessage(), но при обновлении Telegram появилась возможность пользователям запретить пересылку своих сообщений, и при установке данной настройки, при попытках пересылки ботом сообщений владельцу бота, id пользователя отсутствовал, и при прежнем алгоритме бота выпадало исключение (ошибка).
/**
 * Класс WebHook
 */
class WebHook {
   /**
   * Создаем объект WebHook
   */
  constructor(update) {
    // создаем объект бота
    this.bot = new Bot(config.token, update);
    // создаем объект пользователя
    this.user = new User(this.bot.getUserData());
    // создаем объект языковых настроек
    this.lang = new Lang(this.user.lang);
    // получаем набор команд с шаблонами
    this.linkCommands = config.linkCommands;
    // запускаем роутер
    this.route();
  }
  
  /**
   * Получаем объект команды
   */
  checkCommand(text) {
    // текстовые ссылки
    if (this.linkCommands.length > 0) {
      // перебираем команды
        for (let linkCommand of this.linkCommands)
          // если есть совпадения
          if (linkCommand.template.test(text)) {
            // добавим флаг 
            linkCommand.result = true;
            // вернем объект с методом
            return linkCommand;
          }
      }
    // если дошли до этой строчки то вернем флаг false
    return {
      result: false
    };
  }

  /**
   * Маршрутизируем
   */
  route() {
    // проверим на частный запрос 
    if(this.bot.data.message.chat.type != "private") {
      // выйдем если это группа или канал
      return;
    } 
    // если это сообщение
    if(Helper.isSet(this.bot.data.message)) {
      // если это текстовое сообщение
      if(Helper.isSet(this.bot.data.message.text)) {
        // проверяем на команды
        let command = this.checkCommand(this.bot.data.message.text);
        // если есть совпадение по шаблону
        if (command.result) {
          // вызываем метод
          this[command.method]();
          // выходим
          return;
        }
      }
      // если пишет админ
      if (this.isAdmin()) {
        // если это ответ на сообщение
        if (Helper.isSet(this.bot.data.message.reply_to_message)) {
          // получаем текст из отвечаемого сообщения
          let text_ = Helper.isSet(this.bot.data.message.reply_to_message.text)
            ? this.bot.data.message.reply_to_message.text // текстовое сообщение
            : this.bot.data.message.reply_to_message.caption; // медиа сообщение
          // если ответ самому себе
          if (this.user.uid == this.bot.data.message.reply_to_message.from.id) {
            // уведомляем админа, что ответ самому себе
            this.bot.sendMessage(config.botAdmin, this.lang.getParam("admin.answer.self"));
          } // если ответ на сообщение бота
          else if (this.isReplyBot() && !/^USER_ID::[\d]+::/.test(text_)) {
            // уведомляем, что ответ боту
            this.bot.sendMessage(config.botAdmin, this.lang.getParam("admin.answer.bot"));
          } 
          else {
            // получить id пользователя из сообщения
            let matches = text_.match(/^USER_ID::(\d+)::/);
            // проверяем
            if (matches) {
              // все нормально отправляем копию сообщения пользователю
              this.bot.copyMessage(matches[1], config.botAdmin, this.bot.data.message.message_id);
            } else {
              // уведомляем, что не удалось направить сообщение пользователю
              this.bot.sendMessage(config.botAdmin, this.lang.getParam("admin.answer.error.send"));
            }
          }
        } else {
          // уведомление нажать кнопку ответить
          this.bot.sendMessage(config.botAdmin, this.lang.getParam("admin.answer.button.reply"));
        }
      } else {
        // Если это написал пользователь то отправляем копию админу
        this.sendCopyToAdmin();
      }
    } 
  }

  /**
   * Проверяем на Админа
   */
  isAdmin() {
    // сравним текущего пользователя с админом из настроек
    return config.botAdmin == this.user.uid;
  }
  
  /**
   * Локальная проверка на бота
   */
  isReplyBot() {
    // вернем кто владелец сообщения на которое отвечаем
    return this.bot.data.message.reply_to_message.from.is_bot;
  }

  /**
   * Старт бота
   */
  start() {
    // определяем текст
    let text = this.isAdmin() // проверяем кто стартанул
        ? this.lang.getParam("admin.hello") // если стартанул админ
        : this.lang.getParam("user.hello", { // если стартанул пользователь
            name: this.user.name // добавим для парсинга его имя
        });
    // выводим сообщение
    this.bot.sendMessage(this.user.uid, text);
  }

  /**
   * Отправляем копию
   */
  sendCopyToAdmin() {
    // создаем ссылку на просмотр профиля
    let link = (this.user.userName.length > 0)
      ? "@" + this.user.userName // если есть username
      : "<a href='tg://user?id=" + this.user.uid + "'>" + this.user.name + "</a>";
    // дополнение к сообщению с id пользователя
    let dop = "USER_ID::" + this.user.uid + "::\nот <b>" + this.user.name + "</b> | " + link + "\n-----\n";
    // определяем данные по умолчанию
    let typeMessage = this.bot.getMessageType(); // тип сообщения
    let dopSend = false; // по умолчанию доп отправлять отдельно не нужно
    let data = { // формируем данные сообщения
      chat_id: String(config.botAdmin), // пользователь админ
      disable_web_page_preview: true, // закроем превью ссылок
      parse_mode: "HTML", // форматирование html
      method: null // метод по умолчанию не определен
    };
    // если это текстовое сообщение
    if (typeMessage == "text") {
      // формируем доп с текстом
      data.text = dop + this.bot.prepareMessageWithEntities(this.bot.getMessageText(), this.bot.getEntities());
      // переопределяем метод
      data.method = "sendMessage";
    } else { // если это остальные типы сообщений
      // проверяем нужно ли отправлять dop отдельным сообщением
      dopSend = Helper.isNull(this.bot.getMessageText());
      // заполняем данные
      if(typeMessage == "location") {
        // определяем координаты
        data.longitude = this.bot.data.message.location.longitude;
        data.latitude = this.bot.data.message.location.latitude;
      } else {
        // запоняем файлом
        data[typeMessage] = this.bot.getMessageFileId();
      }
      // если не надо доп, значит описание не пустое
      if (!dopSend) {
        // дополняем описание
        data.caption = dop + this.bot.prepareMessageWithEntities(this.bot.getMessageText(), this.bot.getEntities());
      }
      // переопределяем метод
      data.method = "send" + this.prepareMethod(typeMessage);
    }
    // если метод определен
    if (!Helper.isNull(data.method)) {
      // и нужно отправить доп отдельным сообщением
      if (dopSend) {
        // отправляем админу доп
        this.bot.sendMessage(config.botAdmin, dop);
      }
      // отправляем копию
      this.bot.query({
        method: "post",
        payload: data
      });
    }
  }

  /**
   * Преобразуем переданную строку в camelCase
   */
  prepareMethod(method) {
    return method.split('_') // разделяем по знаку _ в массив
      .map(function(word,index){ // перебираем все значения
        // преобразуем первый символ в верхний регистр, остальное в нижний
        return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
      })
      .join(''); // собираем в одно слово без пробелов
  }
}

* * *

Class Bot

Обычный класс с необходимым набором методов для работы тестируемого бота. Добавил метод форматирование текста, так как при копировании сообщения мы имеем текст и форматирование отдельно друг от друга - очень удобная особенность Телеграм.

Методы класса:

  1. constructor() - принимаем и добавляем в свойства объекта класса необходимые и важные данные, такие как токен бота и объект от Телеграм
  2. getUserData() - метод для выдачи данных пользователя переданных от Телеграм
  3. getEntities() - получаем набор форматирования из объекта сообщения
  4. getMessageText() - получаем текст из сообщения или описание медиа
  5. getMessageType() - получаем тип сообщения, очень удобная штука
  6. getMessageFileId() - если это медиа сообщение, то метод нам поможет из него получить file_id
  7. prepareMessageWithEntities() - метод форматирования текста по переданному набору Entities
  8. sendMessage() - один из методов отправки, описан отдельно в виду его частого использования
  9. copyMessage() - этот метод помогает скопировать сообщение и отправить его в указанный чат
  10. query() - основной метод отправки в Телеграм данных, метод доставки данных определяется в наборе переданной ему конфигурации
/**
 * Класс Бот
 */
class Bot {
  /**
   * Создаем объект класса
   */
  constructor(token, data) {
    // записываем токен бота
    this.token = token;
    // и полученный объект с данными от Телеграм
    this.data = data;
  }

  /**
   * Получаем данные пользователя
   */
  getUserData() {
    // вернем данные для создания обновления пользователя
    return {
      // его uid
      uid: this.data.message.from.id ?? 0,
      // его первое имя
      firstName: this.data.message.from.first_name ?? "",
      // его второе имя
      lastName: this.data.message.from.last_name ?? "",
      // его username
      userName: this.data.message.from.username ?? "",
      // его языковую настройку
      lang: this.data.message.from.language_code ?? "ru"
    }
  }
  
  /**
   * Entities - форматировние
   */
  getEntities() {
    // если это сообщение
    if (Helper.isSet(this.data.message)) {
      // если это текствое сообщение
      if(Helper.isSet(this.data.message.text)) {
        // вернем текстовое форматирование если оно существует
        return this.data.message.entities ?? null;
      } else {
        // если это не текствое сообщение, тогда вернем форматирование описания
        return this.data.message.caption_entities ?? null;
      }
    } else {
      // если это другой тип данных вернем null
      return null;
    }
  }

  /**
   * MessageText - получаем текст или описание объекта
   */
  getMessageText() {
    // медиа объекты с возможным описанием
    let medias = [
      'audio', 
      'document', 
      'photo', 
      'animation', 
      'video', 
      'voice'
      ];
    // если это текствое сообщение
    if (Helper.isSet(this.data.message.text)) {
      // вернем текст сообщения
      return this.data.message.text ?? null;
    } // если это медиа сообщение с описанием
    else if (medias.includes(this.getMessageType())) {
      // вернем описание объекта
      return this.data.message.caption ?? null;
    } else {
      // если не подходит условия вернем null
      return null;
    }
  }

  /**
   * Message Type
   */
  getMessageType() {
    // получаем объект сообщения
    let message = this.data.message;
    // начинаем проверки и при совпадении вернем тип сообщения
    if (Helper.isSet(message.text)) {
      return "text"; // текстовое сообщение
    } else if (Helper.isSet(message.photo)) {
      return "photo"; // картинка
    } else if (Helper.isSet(message.audio)) {
      return "audio"; // аудио файл
    } else if (Helper.isSet(message.document)) {
      return "document"; // документ
    } else if (Helper.isSet(message.animation)) {
      return "animation"; // анимация
    } else if (Helper.isSet(message.sticker)) {
      return "sticker"; // стикер
    } else if (Helper.isSet(message.voice)) {
      return "voice"; // голосовая заметка
    } else if (Helper.isSet(message.video_note)) {
      return "video_note"; // видео заметка
    } else if (Helper.isSet(message.video)) {
      return "video"; // видео файл
    } else if (Helper.isSet(message.location)) {
      return "location"; // местоположение
    }
    // по умолчанию вернем null
    return null;
  }

  /**
   * Message File Id
   */
  getMessageFileId() {
    // получаем объект сообщения
    let message = this.data.message;
    // определяем тип с вернем соответствующий file_id
    if (Helper.isSet(message.photo)) {
      // получаем массив картинок
      let photo = message.photo;
      // вернем самую последнюю - максимальный размер
      return photo[photo.length-1].file_id;
    } else if (Helper.isSet(message.audio)) {
      // аудио файл
      return message.audio.file_id;
    } else if (Helper.isSet(message.document)) {
      // документ
      return message.document.file_id;
    } else if (Helper.isSet(message.animation)) {
      // анимация
      return message.animation.file_id;
    } else if (Helper.isSet(message.sticker)) {
      // стикер
      return message.sticker.file_id;
    } else if (Helper.isSet(message.voice)) {
      // голосовая заметка
      return message.voice.file_id;
    } else if (Helper.isSet(message.video_note)) {
      // видео заметка
      return message.video_note.file_id;
    } else if (Helper.isSet(message.video)) {
      // видео файл
      return message.video.file_id;
    }
    // по умолчанию вернем null
    return null;
  }

  /**
   * Форматирование текста
   */
  prepareMessageWithEntities(text, entities) {
    // проверяем наличие форматирования
    if (entities != null && entities.length > 0) {
      // готовим переменную в нее будем добавлять
      let prepareText = "";
      // перебираем форматирование
      entities.forEach(function(entity, idx, arr){
        // добавляем все что между форматированием
        if (entity.offset > 0) {
          /*
            * старт = если начало больше 0 и это первый элемент то берем сначала с нуля
            * если не первый то берем сразу после предыдущего элемента
            *
            * длина = это разница между стартом и текущим началом
            */
          // определяем начало
          let start = (idx == 0)
            ? 0 
            : (arr[idx - 1].offset + arr[idx - 1].length);
          // определяем длину
          let length = entity.offset - start;
          // добавляем
          prepareText = prepareText + text.substr(start, length);
        }
        // выбираем текущий элемент форматирования
        let charts = text.substr(entity.offset, entity.length);
        // обрамляем в необходимый формат
        if (entity.type == "bold") {
          // полужирный
          charts = "<b>" + charts + "</b>";
        } else if (entity.type == "italic") {
          // курсив
          charts = "<i>" + charts + "</i>";
        } else if (entity.type == "code") {
          // код
          charts = "<code>" + charts + "</code>";
        } else if (entity.type == "pre") {
          // inline код
          charts = "<pre>" + charts + "</pre>";
        } else if (entity.type == "strikethrough") {
          // зачеркнутый
          charts = "<s>" + charts + "</s>";
        } else if (entity.type == "underline") {
          // подчеркнутый
          charts = "<u>" + charts + "</u>";
        } else if (entity.type == "spoiler") {
          // скрытый
          charts = "<tg-spoiler>" + charts + "</tg-spoiler>";
        } else if (entity.type == "text_link") {
          // ссылка текстовая
          charts = "<a href='" + entity.url + "'>" + charts + "</a>";
        }
        // добавляем в переменную
        prepareText = prepareText + charts;
      }) 
      // добавляем остатки текста если такие есть
      prepareText = prepareText + text.substr((entities[entities.length-1].offset + entities[entities.length-1].length));
      // возвращаем результат
      return prepareText;
    }
    // по умолчанию вернем не форматированный текст
    return text;
  }

  /**
  * Отправляем сообщение
  */
  sendMessage(chat_id, text) {
    // готовим данные
    let data = {
      method: "post",
      payload: {
        method: "sendMessage", 
        chat_id: String(chat_id),
        text: text,
        parse_mode: "HTML"
      }
    }
    // вернем результат отправки
    return this.query(data);
  }

  /**
   * Отправляем копию сообщения
   */
  copyMessage(to_id, from_id, message_id) {
    // готовим данные
    let data = {
      method: "post",
      payload: {
        method: "copyMessage",
        chat_id: String(to_id), // кому
        from_chat_id: String(from_id), // откуда
        message_id: message_id // что
      }
    }
    // вернем результат отправки
    return this.query(data);
  }

  /**
   * Запрос в Телеграм
   */
  query(data) {
    return JSON.parse(UrlFetchApp.fetch(config.apiUrl + this.token + "/", data).getContentText());
  }
}

* * *

Class User

Класс Пользователя, нам нужен только для тестирования по хранению данных в Google таблице. Как оказалось, это не сложно, при беглом взгляде у Google API для этого очень много инструментов. Надо будет потом написать класс по типу ActiveRecord, не искал, но скорее всего уже есть такие на GitHub. Но опять же мне для теста.

Методы класса:

  1. constructor() - в конструкторе сразу наполняем свойства пользователя полученными данными
  2. getRowByUid() - получаем номер строки по uid
  3. save() - добавляем или обновляем данные о пользователе
/**
 * Класс Пользователь
 */
class User {
  /**
   * Создаем объект пользователя
   */
  constructor(userData) {
    // заполняем uid
    this.uid = userData.uid;
    // name сразу склеиваем из первого и второго имени
    this.name = (userData.firstName + " " + userData.lastName).trim();
    // заполняем lang из телеги
    this.lang = userData.lang;
    // username если есть 
    this.userName = userData.userName;
    // сохраняем данные
    this.save()
  }

  /**
   * Получаем строку в таблице по uid
   */
  getRowByUid(sheet, uid, range_ = "A1:A") {
    // определяем диапазон ячеек в таблице
    const range = sheet.getRange(range_); 
    // получаем через поиск по переданному uid
    const result = range.createTextFinder(uid).matchEntireCell(true).findNext();
    // вернем результат
    return result // если он не null
            ? result.getRow() // вернем номер строки
            : null; // или null
  }

  /**
   * Обновляем или добавляем пользователя в таблицу
   */
  save() {
    // определяем таблицу и в ней лист
    const sheet = SpreadsheetApp.openById(config.sheet).getSheetByName(config.db.users.table);
    // получаем номер строки или null
    const row = this.getRowByUid(sheet, this.uid);
    // получаем текущую дату-время
    const date = new Date();
    // проверяем строку
    if(row) { // если есть то обновляем данные пользователя - могли быть изменены
      // обновляем имя пользователя
      sheet.getRange(row, config.db.users.name).setValue(this.name);
      // обновляем username
      sheet.getRange(row, config.db.users.userName).setValue(this.userName);
      // обновляем lang
      sheet.getRange(row, config.db.users.lang).setValue(this.lang);
      // обновляем дату-время последнего посещения
      sheet.getRange(row, config.db.users.updated_at).setValue(date.toString());
    } else {
      // если строка не найдена, значит добавляем пользователя в лист
      sheet.appendRow([this.uid, this.name, this.userName, this.lang, date.toString(), date.toString()]);
    }
  }
}

* * *

Class Lang

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

Методы класса:

  1. constructor() - получаем набор текстовых настроек и устанавливаем пользовательскую настройку
  2. setLang() - устанавливаем если присутствует пользовательский набор настроек или по умолчанию
  3. getParamByDot() - этот метод позволяет удобно указывать какую настройку брать по типу "admin.answer.error.send", он по точке разбивает в массив и через рекурсию добирается до нужного значения
  4. getParam() - метод возвращает окончательно сформированную текстовую часть с учетом подстановки динамических значений
/**
 * Класс Lang
 */
class Lang {
  /**
   * Создаем объект Lang
   */
  constructor(userLang = 'ru') {
    // получаем данные из общих настроек
    this.langParams = config.langParams;
    // записываем языковую настроку пользователя
    this.setLang(userLang);
  }
  
  /**
   * Уставнавливаем параметр lang
   */
  setLang(userLang) {
    // если настроки по переданному параметру существуют
    this.lang = Helper.isSet(this.langParams[userLang])
      ? userLang // то устанавливаем
      : 'ru'; // иначе вернем по умолчанию
  }

  /**
   * Получаем значение из массива
   */
  getParamByDot(arr, obj) {
    // получаем первый элемент массива
    let name = arr.shift();
    // проверяем есть ли еще в массиве другие параметры
    if(arr.length > 0) {
      // направляем на рекурсию
      return this.getParamByDot(arr, obj[name]);
    }
    // вернем настройку
    return obj[name];
  }

  /**
   * Готовим значение
   */
  getParam(param, data = {}) {
    // получаем текстовую настройку
    let text = this.getParamByDot(param.split('.'), this.langParams[this.lang]);
    // если настройка не найдена
    if (!Helper.isSet(text)) {
      // то вернем заглушку
      return "Unknown Text";
    } // Если настройка найдена
    else {
      // проверяем переданы ли значения под замену
      if (Object.keys(data).length > 0) {
        // перебираем значения
        for (let key in data) {
          // создаем шаблон
          let template = new RegExp('{' + key  + '}', 'gi');
          // заменяем
          text = text.replace(template, data[key]);
        }
      }
      // вернем настройку
      return text;
    }
  }
}

* * *

Развертывание бота на площадке Google Apps Script

Ну что ж, скрипт готов, теперь его необходимо развернуть для работы. Начнем с простого действия, перейдем по ссылке https://sheet.new - если вы авторизованы в Google, то вы перейдете на новую созданную таблицу.

У таблицы необходимо получить ее id - он находится в адресной строке, далее пропишем его в настройках бота в параметре sheet

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

Перейдем: Расширения > Apps Script

Откроется страница нового проекта "Проект без названия" (можете переименовать)

Заменим все что находится в файле Код.gs (открыт по умолчанию), на содержимое из Нашего_Файла.zip

Наш_Файл.zip
6.6

Если у вас еще нет данных бота, таких как токен бота и его username, то посмотрите мою статью по регистрации бота в Телеграм.

В коде из Нашего_Файла в настройках бота укажем id таблицы (sheet), данные бота (токен, username), ваш id (botAdmin) как владельца бота.

/**
 * Настройки Бота
 */
const config = {
  sheet: "1NcydPS8jth0.............1B4hZ0IOXPLu45Tabfm5A",
  .....,
  token: "54043.....:AAG4FcglR.........o3QFxvyqa-37F3o",
  userNameBot: ".......FeedBackBot",
  .....,
  botAdmin: 000000000,
  .....,
  .....
}

Сохраняем все это дело, можно использовать быстрые клавиши CTRL + S, запустим новое развертывание - это большая синяя кнопка справа вверху "Начать развертывание"

Откроется диалоговое окно, нажимаем на иконку "Шестеренка", выбираем "Веб-приложение"

Заполните поля и нажмите кнопку Начать развертывание

  1. Описание - название развертывания
  2. Запуск от имени - выберите От моего имени
  3. У кого есть доступ - укажите Все, иначе Телеграм не сможет направить данные

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

Подробнее о предоставлении прав можно почитать в документации

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

Google выдаст предупреждение, о том что указанное вами приложение не проверенное и будет остерегать вас давать разрешения, но это же ваш аккаунт и ваше приложение - предлагаю рискнуть и продолжить ... жмите Advanced - или на каком у вас там языке будет ссылка (зависит от выбранного вами языка интерфейса - у меня выдало on English)

Выдаст еще одно предупреждение - жмите Go to ......

В отображенной форме нажимайте кнопку Allow

Все, развертывание создано, из данных показанных в окне, нам нужно ссылка (URL) на веб-приложение, скопируйте ее, далее нужно будет ее добавить в настройки бота

/**
 * Настройки Бота
 */
const config = {
  sheet: "1NcydPS8jth0.............1B4hZ0IOXPLu45Tabfm5A",
  webUrl: "https://script.google.com/macros/s/AKfycbxRQCxSPeUXf...........XMAZjU_8UmW3pi4zx2DAltn_/exec",
  .....
}

Не забудьте сохранить изменения кода CTRL + S,  после сохранения нужно запустить установку webHook, используя уже готовую функцию. Для этого выберите в списке функцию под названием setWebHook() и нажмите кнопку Выполнить

В идеале мы должны получить примерно вот такой ответ от Телеграм

Можно запустить еще одну функцию: getWebHookInfo() - она выведет информацию о текущем состоянии настроенного webHook

Бот готов к использованию! 
Открывайте его нажимайте Старт (/start)

Старт бота от имени обычного пользователя
Старт бота от имени обычного пользователя
Дополнение

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

Выберите Управление развертываниями
Выберите Управление развертываниями
Нажмите иконку
Нажмите иконку "Карандаш"
В поле
В поле "Версия" выберите "Новая версия" и нажмите "Начать развертывание"
Развертывание обновлено жмите
Развертывание обновлено жмите "Готово"

* * *

На этом думаю, что все, больше добавить нечего. С вас если не сложно предложения и комментарии, получилось ли у вас запустить этот пример бота. 

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

  • Ирина [1 год назад]

    Прям человеческое спасибо. Сайтов 100 просмотрела, не получалось. Сейчас бот ответил. Спасибо

  • iMakeBots [1 год назад → Ирина]

    Ирина, спасибо!)

    Подскажите как у вас прошла установка? Сколько времени потребовалось? Все ли понятно было в инструкции?

  • Р̲а̲в̲и̲л̲Ь̲ [1 год назад]

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

  • iMakeBots [1 год назад → Р̲а̲в̲и̲л̲Ь̲]

    class WebHook -> route()

    // ...
    
    // Если это написал пользователь то отправляем копию админу
    this.sendCopyToAdmin();
    // Пишем пользователю
    this.bot.sendMessage(this.user.uid, "Ожидайте ответа..");
    
    // ...
  • Аккаунт [1 год назад]

    Здравствуйте Можете объяснить? У вас в коде есть button. Это кнопка должна быть или я что то не так понял?

    button: {
      reply: "Надо поставить сообщение пользователя в ответ.",
    }
  • iMakeBots [1 год назад → Аккаунт]

    Это не кнопка, а текст для уведомления, вызывается по пути admin.answer.button.reply в class WebHook -> route()

    // ...
    } else {
      // уведомление нажать кнопку ответить
      this.bot.sendMessage(config.botAdmin, this.lang.getParam("admin.answer.button.reply"));
    }
    // ...
  • Аккаунт [1 год назад → iMakeBots]

    Почему то кнопки не появились. Просто отвечаю на сообщение путём свайпа влево

  • iMakeBots [1 год назад → Аккаунт]

    В этом боте нет кнопок. Вы напишите без свайпа - просто в бот, он вам выведет этот текст

  • Аккаунт [1 год назад → iMakeBots]

    А вы можете добавить небольшое дополнение к этому автоответу бота?
    К сообщению одну кнопку по нажатию которой открывается инлайн меню этого бота в котором можно добавлять дополнительный контент (скажем telegra.ph ссылки). Некое подобие хелпа? типа этого бота: @graphrubot
    И если отправить этот инлайн контент в переписку, то что бы бот не отправлял сообщение админу. Чисто для пользователя

  • iMakeBots [1 год назад → Аккаунт]

    Вы имеете в виду кнопку с параметром switch_inline_query_current_chat?  Чтобы при нажатии на нее подставлялся текст: "@ваш_бот ", и выходили бы какие-то ваши заготовленные объекты (картинки, ссылки ... ) по инлайн-запросу, по типу FAQ?

    Ну это уже не в рамках этого бота. Возьму на заметку. Как раз искал пример для инлайн-бота.

  • Аккаунт [1 год назад → iMakeBots]

    Да, именно так, как в боте который я указал выше. Хотелось бы конечно это всё в рамках бота обратной связи сделать.

  • iMakeBots [1 год назад → Аккаунт]

    Как продолжение функционала бота обратной связи вполне логично.

  • Аккаунт [1 год назад → iMakeBots]

    Жду с нетерпеньем. Спасибо!

  • Сергей Ларичев(Череповец) [1 год назад]

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

  • iMakeBots [1 год назад → Сергей Ларичев(Череповец)]

    Возможно конечно, передавайте дату по кнопке, делайте выборку из бд и отправляйте в бот

  • Сергей Ларичев(Череповец) [1 год назад → iMakeBots]

    оххх, для меня сложная задачка буду разбираться, спасибо большое 

  • iMakeBots [1 год назад → Сергей Ларичев(Череповец)]

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

  • Сергей Ларичев(Череповец) [1 год назад → iMakeBots]

    Хорошо, спасибо вам огромное 

  • seller acc [1 год назад]

    Супер!!!! даже лучше чем первый был бот обратной связи

    подскажите пожалуйста, сколько ботов можно ставить на одном аккаунте гугл?

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

    Спасибо. Специально не искал эту инфу, но думаю неограниченно.))

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

    и еще, подскажите пожалуйста в какую строку именно нужно вставлять данный код

    // ...
    
    // Если это написал пользователь то отправляем копию админу
    this.sendCopyToAdmin();
    // Пишем пользователю
    this.bot.sendMessage(this.user.uid, "Ожидайте ответа..");
    
    // ...
  • iMakeBots [1 год назад → seller acc]

    Не надо никуда это вставлять - это уже там где надо))

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

    я перепутал, не тот код написал, сейчас исправил

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

    В class WebHook -> route()

    После вот этой строки

    // Если это написал пользователь то отправляем копию админу
    this.sendCopyToAdmin();
  • seller acc [1 год назад → iMakeBots]
    class WebHook {
       /**
       * Создаем объект WebHook
       */
      constructor(update) {
        // создаем объект бота
        this.bot = new Bot(config.token, update);
        // создаем объект пользователя
        this.user = new User(this.bot.getUserData());
        // создаем объект языковых настроек
        this.lang = new Lang(this.user.lang);
        // получаем набор команд с шаблонами
        this.linkCommands = config.linkCommands;
        // запускаем роутер
        this.route();
        // Если это написал пользователь то отправляем копию админу
        this.sendCopyToAdmin();
        // Пишем пользователю
        this.bot.sendMessage(this.user.uid, "Ожидайте ответа..");
    
      }

    правильно же сделал?

    а то что то не работает((

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

    Нет, не правильно. В методе route(), под указанной строкой добавьте отправку уведомления админу

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

    для меня это тяжеловато((

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

    Здравствуйте, а как сделать, что бы в таблице в лист "user" прилетало не только айди, время, но и текст сообщения?

    Делал первый раз все по инструкции.  

    Или направьте меня, где есть инструкция как выводитьв гугл таблицу вопросы заданные боту 

  • Светлана [1 год назад]

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

  • iMakeBots [1 год назад → Светлана]

    Можно создать группу, добавить бота в группу, и направлять сообщения в нее. 

  • Chell [9 месяцев назад]

    Красава! Просто лучший. Однозначно.

    А возможно ли как то в этом боте реализовать возможность его запуска, только подписчиками определенного канала/группы? Или как в принципе такую возможность организовать? Нигде найти не могу, ничего подобного, только через платные сервисы. Не подскажите?

  • iMakeBots [9 месяцев назад → Chell]

    Можно в методе doPost() перед строкой  new WebHook(update); поставить условие и проверять через метод Телеграм getChatMember() присутствие пользователя в супергруппе или канале, если пользователь не найден то вернуть ошибку и остановить выполнение скрипта

  • Chell [9 месяцев назад → iMakeBots]

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

    В любом случае, спасибо за ответ!

  • iMakeBots [9 месяцев назад → Chell]

    Попробуйте вот так, не проверял - на "коленке" написал:

    /**
     * Получаем данные от Телеграм
     */
    function doPost(request) {  
      // получаем данные
      let update = JSON.parse(request.postData.contents);
      // сделаем запрос на проверку пользователя на подписку в канале/группе
      const result = JSON.parse(UrlFetchApp.fetch(config.apiUrl + config.token + "/", {
        method: "post",
        payload: {
          method: "getChatMember",
          chat_id: String(config.channel_id), // id канала\группы
          user_id: String(update.message.from.id),
        }
      }).getContentText());
      // если пользователь в канале\группе
      if(result.ok) {
        // направляем данные в объект WebHook
        new WebHook(update);
      }
    }

    В настройках добавьте config.channel_id

  • Chell [9 месяцев назад → iMakeBots]

    Здравствуйте!

    В настройках, в первых строках скрипта? Пробовал в таком формате как ботадмин: [botAdmin: 000000000,] только: [config.channel_id:-0000000000000,]выше и ниже этой строки, с запятой и без, с тире перед цифрами ID и без, ошибки начинают подчеркиваться, (без квадратных скобок естественно) 

    Что то я не то делаю походу...

  • iMakeBots [9 месяцев назад → Chell]

    Попробуйте в таком формате

    …
    token: "540.....83:AAG4.......0DgsEo3QFx.....7F3o",
    channel_id: -00000000000,
    …
  • Chell [9 месяцев назад → iMakeBots]

    Да, так ошибок нет, но бот работать перестал (

  • iMakeBots [9 месяцев назад → Chell]

    Если все правильно, то может просто проверка не проходит? То есть когда вы стартует бот он вас в группе ненаходит и останавливает работу

  • Chell [9 месяцев назад → iMakeBots]

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

    Развертывание\обновление, все по инструкции делал.

  • iMakeBots [9 месяцев назад → Chell]

    До этого работал?

  • Chell [9 месяцев назад → iMakeBots]

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

  • iMakeBots [9 месяцев назад → Chell]

    Id группы верный?

    Надо посмотреть что возвращает result, не тестировал, так как надо разворачивать бот - поэтому и не отлаженный код

  • Chell [9 месяцев назад → iMakeBots]

    Если верить боту LeadConverter да

  • Viktor [6 месяцев назад]

    Здравствуйте. Я новичок в деле создания ботов, и хотел бы описать ситуацию, из которой ищу выход.

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

    Всё было хорошо, пока  я не столкнулся с ограничением в 100 сообщений в день и теперь ищу выход из ситуации.

    Можно ли на основе телеграм бота реализовать подобный функционал?

    Т.е. мне не нужно, что бы пользователи что то у бота спрашивали и он отвечал, мне нужно, что бы он просто отправлял определённые сообщение указанным абонентам по команде от гугл скрипт.

    Копать в эту сторону или в какую то другую?))

    Спасибо.