Pull to refresh

Бот для Telegram. Rails way

Reading time 6 min
Views 24K
Этот пост о библиотеке 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.

Приятной разработки!
Tags:
Hubs:
+8
Comments 4
Comments Comments 4

Articles