Telegram bot. Rails way.

This post is about telegram-bot library for creating Telegram bots. It is targeted at ease of development, debugging, and testing bots, keeping interfaces minimalistic, easy integration with Rails application, and providing all necessary tools to build a bot. What’s inside:

  • Lightweight client for bot-API.
  • Base class for updates controller with message parser. It’s based on AbstractController from ActionDispatch, provides callbacks, sessions, saving message context, etc.
  • Rack-middleware to receive update-hooks in production, and poller with source reloader for convenient development.
  • Rake tasks, route- and test-helpers.

Bot-API client

It’s easy to create a client: `Telegram::Bot::Client.new(token, username)`. `username` is optional and used to parse commands with mentions (`/cmd@BotName`) and in the prefix for session id.

Basic client’s method is `request(path_suffix, body)`. There are shortcuts for all the commands from docs with underscored names (`.send_message(body)`, `answer_inline_query(body)`). All this methods just POST given data on specific URL. Files from `body` will be automatically sent as `multipart/form-data`, and nested hashes encoded as 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)

By default client will return parsed JSON for every request. It can return Virtus models with using `telegram-bot-types` gem:

# Add to gemfile Gemfile:
gem ‘telegram-bot-types’, ‘~> x.x.x’
# Enable typecasting for all bots:
Telegram::Bot::Client.typed_response!
# or for single client:
bot.extend Telegram::Bot::Client::TypedResponse
bot.get_me.class # => Telegram::Bot::Types::User

Configuration

Gem adds methods to configure and access application-wide clients to the `Telegram` module:

# Add bots settings:
Telegram.bots_config = {
# Using only token
default: ‘bot_token’,
# or with username
chat: {
token: ‘other_token’,
username
}
}
# And bots are accessible with
Telegram.bots[:chat].send_message(params)
Telegram.bots[:default].send_message(params)
# For :default default bot there is shortcut.
# Useful when it's single bot in the app.
Telegram.bot.get_me

Clients are thread-safe, there will be no problems in multi-thread apps.

You can avoid setting `bots_config` manually in Rails app and it’ll be read from `secrets.yml`:

development:
telegram:
bots:
chat: TOKEN_1
default:
token: TOKEN_2
username: ChatBot
# This will be merged as bots.default
bot:
token: TOKEN
username: SomeBot

Controllers

There is base controller class to process updates. All its public methods are used as action-methods for commands just like in ActionController. Example: when `/cmd arg 1 2` arrives, it calls `cmd(‘arg’, ‘1’, ‘2’)` method (if it is defined and public). Unlike ActionController all unsupported commands are just ignored without ActionMissing exceptions.

Controller supports commands with mentions. When such command received, it compares name from command with bot’s `username`. It treats message as command in the case of match, otherwise as usual text message.

To process other updates (not messages) methods named with type name are used (there are 3 for now: message, inline_query, chosen_inline_result). This methods receives corresponding object as argument.

There are helpers `reply_with(type, params)` and `answer_inline_query(results, params)` to reply to the received update.

class TelegramWebhookController < Telegram::Bot::UpdatesController
def message(message)
reply_with text: "Echo: #{message['text']}"
end
  def start(*)
# There are helpers for `chat` & `from`:
reply_with text: "Hello #{from['username']}!" if from
# Message object can be accessed with #payload:
log { "Started at: #{payload['date']}" }
end
  # Be sure to use splat args and default values 
# to not get errors when someone passed
# more or less arguments in the message.
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

Most likely bot will need to store chat state between messages. Session can be used for this. Interface is similar to ActionController’s session interface, but differs in used stores. Any ActiveSupport::Cache-compatible store can be used as adapter (ex.,`redis-activesupport`).

`#session_key` method is used to set session id, it can be changed by overriding this method. Default value is:

def session_key
“#{bot.username}:" \
"#{from ? “from:#{from[‘id’]}” : “chat:#{chat[‘id’]}”}”
end

Message context can be implemented using sessions. This is support for commands split into multiple messages: user sends command without arguments, and bot clarifies what arguments it expects, and user sends them in following message(-s) (like BotFather do). This is available in `Telegram::Bot::UpdatesController::MessageContext` module:

class TelegramWebhookController < Telegram::Bot::UpdatesController
include Telegram::Bot::UpdatesController::MessageContext
  def rename(*)
# Save context for next message:
save_context :rename
reply_with :message, text: 'What name do you like?'
end
  # Set handler for this context:
context_handler :rename do |message|
update_name message[:text]
reply_with :message, text: 'Renamed!'
end
  # This can be done in other way.
# Let #rename support support commands with argument:
def rename(name = nil, *)
if name
update_name name
reply_with :message, text: 'Renamed!'
else
# Save context when argument is not given:
save_context :rename
reply_with :message, text: 'What name do you like?'
end
end
  # Without given block it'll used method with name
# same as context.
# Action will be processed with all the callbacks.
# Just like '/rename %text%' message was received.
context_handler :rename
  # In the case when there are  a lot of such contexts:
context_to_action!
# This way it'll use actions with context name for every
# handler which is not explicitly set.
end

Integrating with app

Controller can be used in several ways:

# To process update:
ControllerClass.dispatch(bot, update)
# To call action manually without update:
controller = ControllerClass.new(bot,
from: telegram_user,
chat: telegram_chat,
)
controller.process(:help, *args)

There is Rack-endpoint to handle update-hooks. There is also route-helpers for Rails applications: it’ll use bot token as path suffix. For single bot it’ll be enough to add:

# routes.rb
telegram_webhooks Telegram::WebhookController

Using this helper allows to perform `setWebhook` request for all the bots with generated URLs with rake task:

rake telegram:bot:set_webhook RAILS_ENV=production

Testing

Gem has `Telegram::Bot::ClientStub`, to stub all API clients in tests. Instead of performing API requests it stores them in `#requests` hash.

To stub all possible clients use Telegram::Bot::ClientStub.stub_all! before initializing clients:

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

There are helpers to test controllers the same way as 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

Development & Debugging

There is updates poller for local debugging. `rake telegram:bot:poller` will run the poller. It’ll automatically reload sources when processing updates, so there is no need to restart process manually.

Gem’s source and detailed documentation are available at github.

Happy codding!