Бот для Telegram. Rails way

    Этот пост о библиотеке telegram-bot для написания ботов для Telegram. В числе основных целей при её создании были удобство разработки, отладки и тестирования ботов, сохранение интерфейсов минимальными, но с возможностью расширения, простота интеграции с Rails-приложением, и предоставление необходимых инструментов для написания бота. Вот что входит в состав:

    • Легковесный клиент для API ботов.
    • Базовый класс для контроллера обновлений с парсером сообщений. Сделан на основе AbstractController из ActionDispatch, предоставляет колбэки, сессии, сохранение контекста сообщений и прочее.
    • Rack-middleware для продакшена, чтобы принимать update-хуки, и поллер с автоматической загрузкой обновленного кода для удобной разработки.
    • Rake таски, хэлперы для рельсовых маршрутов и тестов.

    Интересно? Для установки добавьте telegram-bot в Gemfile, подробности под катом.

    Клиент к bot-API


    Создать клиента просто: Telegram::Bot::Client.new(token, username). Значение username опционально и используется для парсинга команд с обращениями (/cmd@BotName) и в префиксе ключа сессии в Key-Value хранилище.

    Базовый метод клиента — request(path_suffix, body), для всех команд из документации есть шорткаты в стиле Ruby — с подчеркиваниями (.send_message(body), answer_inline_query(body)). Все эти методы просто выполняют POST с переданными параметрами на нужный URL. Файлы в body будут автоматически переданы с multipart/form-data, а вложенные хэши закодированны в json, как требует документация.

    bot.request(:getMe) or bot.get_me
    bot.request(:getupdates, offset: 1) or bot.get_updates(offset: 1)
    bot.send_message chat_id: chat_id, text: 'Test'
    bot.send_photo chat_id: chat_id, photo: File.open(photo_filename)

    Из коробки клиент на каждый запрос будет возвращать обычный распрарсенный json. Можно воспользоваться гемом telegram-bot-types и получать на выходе virtus-модели:

    # Добавьте в Gemfile:
    gem 'telegram-bot-types', '~> x.x.x'
    # Включите typecasting для всех ботов:
    Telegram::Bot::Client.typed_response!
    # или для отдельного клиента:
    bot.extend Telegram::Bot::Client::TypedResponse
    
    bot.get_me.class # => Telegram::Bot::Types::User

    Настройка


    Гем добавляет в модуль Telegram методы для настройки и доступа к общим для приложения клиентам (они потокобезопасны, проблем с несколькими потоками не возникнет):

    # Добавьте настройки
    Telegram.bots_config = {
      # Можно указать только токен
      default: 'bot_token',
      # или вместе с username
      chat: {
        token: 'other_token',
        username
      }
    }
    
    # Теперь боты будут доступны так:
    Telegram.bots[:chat].send_message(params)
    Telegram.bots[:default].send_message(params)
    
    # Для :default бота есть шорткат (удобно, если он единственный):
    Telegram.bot.get_me

    Для Rails приложений можно обойтись без ручной настройки bots_config, конфиг будет прочитан из secrets.yml:

    development:
      telegram:
        bots:
          chat: TOKEN_1
          default:
            token: TOKEN_2
            username: ChatBot
        # Это будет вмержено как bots.default
        bot:
          token: TOKEN
          username: SomeBot

    Контроллеры


    Для обработки обновлений в геме есть базовый класс контроллера. Как и в ActionController, все публичные методы используются в качестве action-методов для обработки команд. То есть, если приходит сообщение /cmd arg 1 2, то будет вызван метод cmd('arg', '1', '2') (если он определён и публичный). В отличии от ActionController, если приходит неподдерживаемая команда, то она просто игнорируется, без ошибок ActionMissing.

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

    Для обработки других обновлений (не сообщений) нужно также определить публичные методы с именем из названия типа обновления (сейчас их доступно 3: `message, inline_query, chosen_inline_result'). Эти методы получают в качестве аргумента соответствующий объект из обновления.

    Для ответа на пришедшее уведомление есть хэлперы reply_with(type, params) и answer_inline_query(results, params), которые выставляют получателя и другие поля из пришедшего обновления.

    class TelegramWebhookController < Telegram::Bot::UpdatesController
      def message(message)
        reply_with text: "Echo: #{message['text']}"
      end
    
      def start(*)
        # Есть хэлперы для chat и from:
        reply_with text: "Hello #{from['username']}!" if from
        # Доступ к самому сообщению можно получить через payload:
        log { "Started at: #{payload['date']}" }
      end
    
      # При объявлении команд следует обязательно использовать splat-аргументы и
      # значения по-умолчанию, потому что пользователи могут написать команду
      # как с лишними параметрами, так и без них вообще.
      def help(cmd = nil, *)
        message =
          if cmd
            help_for_cmd?(cmd) ? t(".cmd.#{cmd}") : t('.no_help')
          else
            t('.help')
          end
        reply_with text: message
      end
    end

    Скорее всего боту понадобится запоминать состояние чата между сообщениями. Для этого в контроллере можно воспользоваться сессией. Интерфейс схож с интерфейсом сессии в ActionController, различие в способе хранения. В качестве адаптера можно использовать любое ActiveSupport::Cache-совместимое хранилище (redis-activesupport, например).

    По-умолчанию в качестве ИД сессии используется такое значение (его можно изменить, переопределив метод):

    def session_key
      "#{bot.username}:#{from ? "from:#{from['id']}" : "chat:#{chat['id']}"}"
    end

    Используя сессии, можно реализовать контекст сообщений — поддержка команд, пересылаемые в нескольких сообщениях: пользователь отправляет комманду без аргументов, бот уточняет, какие аргументы он ожидает, и пользователь отправляет их в следующем сообщении(-ях) (как это делает BotFather, например). Такой функционал доступен в модуле Telegram::Bot::UpdatesController::MessageContext:

    class TelegramWebhookController < Telegram::Bot::UpdatesController
      include Telegram::Bot::UpdatesController::MessageContext
    
      def rename(*)
        # Сохраним контекст для следующего сообщения:
        save_context :rename
        reply_with :message, text: 'What name do you like?'
      end
    
      # Зададим хэндлер для этого контекста:
      context_handler :rename do |message|
        update_name message[:text]
        reply_with :message, text: 'Renamed!'
      end
    
      # Можно сделать по-другому. Определим rename, чтобы он мог обрабатывать команды
      # с переданным аргументом.
      def rename(name = nil, *)
        if name
          update_name name
          reply_with :message, text: 'Renamed!'
        else
          # Если аргумент не указан, то сохраняем контекст:
          save_context :rename
          reply_with :message, text: 'What name do you like?'
        end
      end
    
      # Без блока для обработки контекста будет использован тот же метод, что и название контекста.
      # Экшн будет выполнен со всеми колбэками, точно так же, как если бы пришло
      # сообщение '/rename %text%'
      context_handler :rename
    
      # Если таких контекстов много, можно использовать:
      context_to_action!
      # При этом для всех явно не заданных контекстов будет использован экшн по его названию.
    end

    Интеграция в приложение


    Контроллер можно использовать в нескольких вариантах:

    # Для обработки обновления:
    ControllerClass.dispatch(bot, update)
    
    # Вызвать экшн вручную, без обновления.
    controller = ControllerClass.new(bot, from: telegram_user, chat: telegram_chat)
    controller.process(:help, *args)

    Для обработки хуков есть Rack-endpoint. Для Rails приложений есть хэлперы маршрутов: в качестве суффикса пути будет использован токен бота. При использовании единственного бота в приложении достаточно добавить:

    # routes.rb
    telegram_webhooks Telegram::WebhookController

    Использование этого хэлпера позволяет выполнить setWebhook для ботов, использую получившиеся URL, с помощью таски:

    rake telegram:bot:set_webhook RAILS_ENV=production

    Тестирование


    В геме есть Telegram::Bot::ClientStub, чтобы заменить клиентов API в тестах. Вместо выполнения запросов он сохраняет их в хэше #requests. Чтобы застабить всех создаваемых клиентов и не отправлять запросы к Telegram во время выполнения тестов, можно написать так:

    RSpec.configure do |config|
      # ...
      Telegram.reset_bots
      Telegram::Bot::ClientStub.stub_all!
      config.after { Telegram.bot.reset }
      # ...
    end

    Есть хэлперы для тестирования контроллеров так же, как и ActionController:

    require 'telegram/bot/updates_controller/rspec_helpers'
    
    RSpec.describe TelegramWebhookController do
      include_context 'telegram/bot/updates_controller'
    
      describe '#rename' do
        subject { -> { dispatch_message "/rename #{new_name}" } }
        let(:new_name) { 'new_name' }
        it { should change { resource.reload.name }.to(new_name) }
      end
    end

    Разработка и отладка


    Для локальной отладки можно запустить поллер обновлений. Для этого скорее всего понадобится создать отдельного бота. rake telegram:bot:poller запустит поллер. Он автоматически будет загружать обновления кода при обработке обновлений, нет необходимости перезапускать процесс.

    Исходный код и более подробное описание доступны на github.

    Приятной разработки!
    Метки:
    • +8
    • 12,2k
    • 3
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 3
    • 0
      Оригинальный README.md выглядит как-то внушительнее даже.
      • 0
        Спасибо большое за эту статью!
        Без нее я бы не нашел это gem!
        • 0

          Буду рад, если гем поможет вам запустить несколько классных ботов :)

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