1
0
зеркало из https://github.com/glebtv/telegram-bot.git synced 2026-09-03 17:55:52 +03:00

Сравнить коммиты

..

19 Коммитов

Автор SHA1 Сообщение Дата
Max Melentiev
232eab1d5b v0.5.0 2016-03-14 14:30:14 +06:00
Max Melentiev
9fe4c6bcae Fixed Readme, added spec for protected methods in controller 2016-03-14 14:20:16 +06:00
Max Melentiev
c04884040d Client.wrap supports symbols 2016-03-14 14:19:57 +06:00
Max Melentiev
225290b677 Easily run UpdatesController's actions without updates 2016-03-11 14:32:57 +06:00
Max Melentiev
e03e39bd53 MessageContext 2016-03-05 21:40:09 +06:00
Max Melentiev
e767a65faa Testing: run multiple dispatches on same instance 2016-03-04 20:13:41 +06:00
Max Melentiev
32b6794924 Ability to stub all clients. 2016-03-04 15:27:41 +06:00
Max Melentiev
2a511abe16 fixed middleware's response 2016-02-29 14:22:36 +06:00
Max Melentiev
0f5f426d55 v0.4.1 2016-02-29 14:08:31 +06:00
Max Melentiev
e6dea5096f Properly included ActiveRecord::Railties::ControllerRuntime 2016-02-29 14:07:11 +06:00
Max Melentiev
c06ba2af5c Fixed set_webhook rake task 2016-02-29 14:06:11 +06:00
Max Melentiev
933bb337ef v0.4.0 2016-02-28 21:55:01 +06:00
Max Melentiev
f549ed53a5 ClientStub and improved rspec helpers 2016-02-28 21:53:54 +06:00
Max Melentiev
bff412360e Sessions for UpdatesController 2016-02-28 21:51:59 +06:00
Max Melentiev
298947f970 Halt processing in UpdatesController when callback returns false 2016-02-26 17:18:54 +06:00
Max Melentiev
ccb84c744a Client#debug! supports block 2016-02-26 16:27:43 +06:00
Max Melentiev
1b12435d8b Encodes nested hashes in request body as json 2016-02-26 16:23:07 +06:00
Max Melentiev
d127a52364 transfered gem to github organization 2016-02-23 14:16:53 +06:00
Max Melentiev
a1adf17df3 Ability to use type-castion with telegram-bot-types 2016-02-23 14:14:45 +06:00
37 изменённых файлов: 1383 добавлений и 232 удалений

Просмотреть файл

@@ -8,9 +8,11 @@ Style/AlignParameters:
# EnforcedStyle: # EnforcedStyle:
# - with_first_parameter # - with_first_parameter
# - with_fixed_indentation # - with_fixed_indentation
Style/AndOr: {EnforcedStyle: conditionals}
Style/ClosingParenthesisIndentation: {Enabled: false} Style/ClosingParenthesisIndentation: {Enabled: false}
Style/Documentation: {Enabled: false} Style/Documentation: {Enabled: false}
Style/DotPosition: {EnforcedStyle: trailing} Style/DotPosition: {EnforcedStyle: trailing}
Style/FirstParameterIndentation: {EnforcedStyle: consistent}
Style/IfUnlessModifier: {Enabled: false} Style/IfUnlessModifier: {Enabled: false}
Style/ModuleFunction: {Enabled: false} Style/ModuleFunction: {Enabled: false}
Style/MultilineOperationIndentation: {EnforcedStyle: indented} Style/MultilineOperationIndentation: {EnforcedStyle: indented}

7
CHANGELOG.md Обычный файл
Просмотреть файл

@@ -0,0 +1,7 @@
# 0.5.0
- MessageContext.
- Running controller action without update.
- Client.wrap supports symbols.
- Improved testing utils: ability to process multiple updates on same controller instance,
stubbing all clients in application.

Просмотреть файл

@@ -6,6 +6,8 @@ group :development do
gem 'pry', '~> 0.10.1' gem 'pry', '~> 0.10.1'
gem 'pry-byebug', '~> 3.2.0' gem 'pry-byebug', '~> 3.2.0'
gem 'telegram-bot-types', '~> 0.2.0'
gem 'rspec', '~> 3.3.0' gem 'rspec', '~> 3.3.0'
gem 'rspec-its', '~> 1.1.0' gem 'rspec-its', '~> 1.1.0'

164
README.md
Просмотреть файл

@@ -1,17 +1,17 @@
# Telegram::Bot # Telegram::Bot
[![Gem Version](https://badge.fury.io/rb/telegram-bot.svg)](http://badge.fury.io/rb/telegram-bot) [![Gem Version](https://badge.fury.io/rb/telegram-bot.svg)](http://badge.fury.io/rb/telegram-bot)
[![Code Climate](https://codeclimate.com/github/printercu/telegram-bot/badges/gpa.svg)](https://codeclimate.com/github/printercu/telegram-bot) [![Code Climate](https://codeclimate.com/github/telegram-bot-rb/telegram-bot/badges/gpa.svg)](https://codeclimate.com/github/telegram-bot-rb/telegram-bot)
[![Build Status](https://travis-ci.org/printercu/telegram-bot.svg)](https://travis-ci.org/printercu/telegram-bot) [![Build Status](https://travis-ci.org/telegram-bot-rb/telegram-bot.svg)](https://travis-ci.org/telegram-bot-rb/telegram-bot)
Tools for developing bot for Telegram. Best used with Rails, but can be be used in Tools for developing bot for Telegram. Best used with Rails, but can be be used in
standalone app. Supposed to be used in webhook-mode in production, and poller mode standalone app. Supposed to be used in webhook-mode in production, and poller-mode
in development, but you can use poller in production if you want. in development, but you can use poller in production if you want.
Package contains: Package contains:
- Ligthweight client to bot API (with fast and thread-safe - Ligthweight client for bot API (with fast and thread-safe
[httpclient](https://github.com/nahi/httpclient) is under the hood.) [httpclient](https://github.com/nahi/httpclient) under the hood).
- Controller with message parser. Allows to write separate methods for each command. - Controller with message parser. Allows to write separate methods for each command.
- Middleware and routes helpers for production env. - Middleware and routes helpers for production env.
- Poller with automatic source-reloader for development env. - Poller with automatic source-reloader for development env.
@@ -62,23 +62,39 @@ telegram:
From now clients will be accessible with `Telegram.bots[:chat]` or `Telegram.bots[:auction]`. From now clients will be accessible with `Telegram.bots[:chat]` or `Telegram.bots[:auction]`.
Single bot can be accessed with `Telegram.bot` or `Telegram.bots[:default]`. Single bot can be accessed with `Telegram.bot` or `Telegram.bots[:default]`.
You can create clients manually with `Telegram::Bot.new(token, username)`. You can create clients manually with `Telegram::Bot::Client.new(token, username)`.
Username is optional and used only to parse commands with mentions. Username is optional and used only to parse commands with mentions.
Client has all available methods in underscored style There is `request(path_suffix, body)` method to perform any query.
And there are also shortcuts for available queries in underscored style
(`answer_inline_query` instead of `answerInlineQuery`). (`answer_inline_query` instead of `answerInlineQuery`).
All this methods just post given params to specific URL. All this methods just post given params to specific URL.
```ruby ```ruby
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_message chat_id: chat_id, text: 'Test'
``` ```
By default client will return parsed json responses. You can enable
response typecasting to virtus models using `telegram-bot-types` gem:
```ruby
# Add to your gemfile:
gem 'telegram-bot-types', '~> x.x.x'
# Enable typecasting:
Telegram::Bot::Client.typed_response!
# or for single instance:
bot.extend Telegram::Bot::Client::TypedResponse
bot.get_me.class # => Telegram::Bot::Types::User
```
### Controller ### Controller
```ruby ```ruby
class Telegram::WebhookController < Telegram::Bot::UpdatesController class Telegram::WebhookController < Telegram::Bot::UpdatesController
# use callbacks like in any other controllers # use callbacks like in any other controllers
around_action :set_locale around_action :with_locale
# Every update can have one of: message, inline_query & chosen_inline_result. # Every update can have one of: message, inline_query & chosen_inline_result.
# Define method with same name to respond to this updates. # Define method with same name to respond to this updates.
@@ -107,7 +123,7 @@ class Telegram::WebhookController < Telegram::Bot::UpdatesController
private private
def set_locale(&block) def with_locale(&block)
I18n.with_locale(locale_for_update, &block) I18n.with_locale(locale_for_update, &block)
end end
@@ -121,6 +137,105 @@ class Telegram::WebhookController < Telegram::Bot::UpdatesController
end end
``` ```
You can enable typecasting of `update` with `telegram-bot-types` by including
`Telegram::Bot::UpdatesPoller::TypedUpdate`:
```ruby
class Telegram::WebhookController < Telegram::Bot::UpdatesController
include Telegram::Bot::UpdatesController::TypedUpdate
def message(message)
message.class # => Telegram::Bot::Types::Message
end
end
```
There is support for sessions using `ActiveSupport::Cache` stores.
```ruby
# configure store in env files:
config.telegram_updates_controller.session_store = :redis_store, {expires_in: 1.month}
class Telegram::WebhookController < Telegram::Bot::UpdatesController
include Telegram::Bot::UpdatesController::Session
# or just shortcut:
use_session!
# You can override global config
self.session_store = :file_store
def write(text = nil, *)
session[:text] = text
end
def read
reply_with :message, text: session[:text]
end
private
# By default it uses bot's username and user's id as a session key.
# Chat's id is used only when `from` field is empty.
# Override `session_key` method to change this behavior.
def session_key
# In this case session will persist for user only in specific chat:
"#{bot.username}:#{chat['id']}:#{from['id']}"
end
end
```
It's usual to support chain of messages like BotFather: after receiving command
it asks you for additional argument. There is `MessageContext` for this:
```ruby
class Telegram::WebhookController < Telegram::Bot::UpdatesController
include Telegram::Bot::UpdatesController::MessageContext
def rename(*)
# set context for the next message
save_context :rename
reply_with :message, text: 'What name do you like?'
end
# register context handlers to handle this context
context_handler :rename do |message|
update_name message[:text]
reply_with :message, text: 'Renamed!'
end
# You can do it in other way:
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
# This will call #rename like if it is called with message '/rename %text%'
context_handler :rename
# If you have a lot of such methods you can use
context_to_action!
# It'll use context value as action name for all contexts which miss handlers.
end
```
To process update run:
```ruby
ControllerClass.dispatch(bot, update)
```
There is also ability to run action without update:
```ruby
# Most likely you'll want to pass :from and :chat
controller = ControllerClass.new(bot, from: telegram_user, chat: telegram_chat)
controller.process(:help, *args)
```
### Routes ### Routes
Use `telegram_webhooks` helper to add routes. It will create routes for bots Use `telegram_webhooks` helper to add routes. It will create routes for bots
@@ -156,13 +271,36 @@ call `.dispatch(bot, update)` on controller.
### Development & Debugging ### Development & Debugging
Use `rake telegram:bot:poller BOT=chat` to run poller. It'll automatically load Use `rake telegram:bot:poller` to run poller. It'll automatically load
changes without restart in development env. This task will not if you don't use changes without restart in development env. Optionally specify bot to run poller for
`telegram_webhooks`. with `BOT` envvar (`BOT=chat`).
This task will not work if you don't use `telegram_webhooks`.
You can run poller manually with You can run poller manually with
`Telegram::Bot::UpdatesPoller.start(bot, controller_class)`. `Telegram::Bot::UpdatesPoller.start(bot, controller_class)`.
### Testing
There is `Telegram::Bot::ClientStub` class to stub client for 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. Most likely you'll want something like this:
```ruby
RSpec.configure do |config|
# ...
Telegram.reset_bots
Telegram::Bot::ClientStub.stub_all!
config.after { Telegram.bot.reset }
# ...
end
```
There are also some helpers for controller tests.
Check out `telegram/bot/updates_controller/rspec_helpers` and
`telegram/bot/updates_controller/testing`.
### Deploying ### Deploying
Use `rake telegram:bot:set_webhook` to update webhook url for all configured bots. Use `rake telegram:bot:set_webhook` to update webhook url for all configured bots.
@@ -181,4 +319,4 @@ push git commits and tags, and push the `.gem` file to [rubygems.org](https://ru
## Contributing ## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/printercu/telegram-bot. Bug reports and pull requests are welcome on GitHub at https://github.com/telegram-bot-rb/telegram-bot.

Просмотреть файл

@@ -12,9 +12,10 @@ namespace :telegram do
routes = Rails.application.routes.url_helpers routes = Rails.application.routes.url_helpers
cert_file = ENV['CERT'] cert_file = ENV['CERT']
cert = File.open(cert_file) if cert_file cert = File.open(cert_file) if cert_file
Telegram.bots.each_value do |bot| Telegram.bots.each do |key, bot|
route_name = Telegram::RoutesHelper.route_name_for_bot(bot) route_name = Telegram::Bot::RoutesHelper.route_name_for_bot(bot)
url = routes.send("#{route_name}_url") url = routes.send("#{route_name}_url")
puts "Setting webhook for #{key}..."
bot.set_webhook(url: url, certificate: cert) bot.set_webhook(url: url, certificate: cert)
end end
end end

Просмотреть файл

@@ -1,97 +1,17 @@
require 'httpclient' require 'telegram/bot/config_methods'
require 'json'
require 'active_support/core_ext/string/inflections'
require 'active_support/core_ext/hash/keys'
require 'active_support/core_ext/array/wrap'
require 'telegram/bottable'
module Telegram module Telegram
extend Bottable extend Bot::ConfigMethods
class Bot module Bot
class Error < StandardError; end class Error < StandardError; end
class NotFound < Error; end class NotFound < Error; end
autoload :Client, 'telegram/bot/client'
autoload :ClientStub, 'telegram/bot/client_stub'
autoload :Middleware, 'telegram/bot/middleware' autoload :Middleware, 'telegram/bot/middleware'
autoload :UpdatesController, 'telegram/bot/updates_controller' autoload :UpdatesController, 'telegram/bot/updates_controller'
autoload :UpdatesPoller, 'telegram/bot/updates_poller' autoload :UpdatesPoller, 'telegram/bot/updates_poller'
URL_TEMPLATE = 'https://api.telegram.org/bot%s/'.freeze
class << self
# Accepts different options to initialize bot.
def wrap(input)
case input
when self then input
when Array then input.map(&method(__callee__))
when Hash then
input = input.stringify_keys
new input['token'], input['username']
else
new(input)
end
end
end
attr_reader :client, :token, :username, :base_uri
def initialize(token, username = nil)
@client = HTTPClient.new
@token = token
@username = username
@base_uri = format URL_TEMPLATE, token
end
def debug!(dev = STDOUT)
client.debug_dev = dev
end
def debug_off!
client.debug_dev = nil
end
def request(action, data = {})
res = http_request("#{base_uri}#{action}", data)
status = res.status
return JSON.parse(res.body) if 300 > status
result = JSON.parse(res.body) rescue nil # rubocop:disable RescueModifier
err_msg = "#{res.reason}: #{result && result['description'] || '-'}"
# NotFound is raised only for valid responses from Telegram
raise NotFound, err_msg if 404 == status && result
raise Error, err_msg
end
%w(
answerInlineQuery
forwardMessage
getFile
getMe
getUpdates
getUserProfilePhotos
sendAudio
sendChatAction
sendDocument
sendLocation
sendMessage
sendPhoto
sendSticker
sendVideo
sendVoice
setWebhook
).each do |method|
define_method(method.underscore) { |*args| request(method, *args) }
end
# Endpoint for low-level request. For easy host highjacking & instrumentation.
# Params are not used directly but kept for instrumentation purpose.
# You probably don't want to use this method directly.
def http_request(uri, body)
client.post(uri, body)
end
def inspect
"#<Telegram::Bot##{object_id}(#{@username})>"
end
end end
end end

114
lib/telegram/bot/client.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,114 @@
require 'json'
require 'httpclient'
require 'active_support/core_ext/string/inflections'
require 'active_support/core_ext/hash/keys'
module Telegram
module Bot
class Client
autoload :TypedResponse, 'telegram/bot/client/typed_response'
URL_TEMPLATE = 'https://api.telegram.org/bot%s/'.freeze
class << self
# Accepts different options to initialize bot.
def wrap(input)
case input
when self then input
when Array then input.map(&method(__callee__))
when Hash then
input = input.stringify_keys
new input['token'], input['username']
when Symbol
Telegram.bots[input] or
raise "Bot #{input} not configured, check Telegram.bots_config."
else
new(input)
end
end
# Prepend TypedResponse module.
def typed_response!
prepend TypedResponse
end
# Encodes nested hashes as json.
def prepare_body(body)
body.each do |k, val|
body[k] = val.to_json if val.is_a?(Hash)
end
end
end
attr_reader :client, :token, :username, :base_uri
def initialize(token, username = nil)
@client = HTTPClient.new
@token = token
@username = username
@base_uri = format URL_TEMPLATE, token
end
def debug!(dev = STDOUT)
if block_given?
begin
old_dev = client.debug_dev
client.debug_dev = dev
yield
ensure
client.debug_dev = old_dev
end
else
client.debug_dev = dev
end
end
def debug_off!
client.debug_dev = nil
end
def request(action, body = {})
res = http_request("#{base_uri}#{action}", self.class.prepare_body(body))
status = res.status
return JSON.parse(res.body) if 300 > status
result = JSON.parse(res.body) rescue nil # rubocop:disable RescueModifier
err_msg = "#{res.reason}: #{result && result['description'] || '-'}"
# NotFound is raised only for valid responses from Telegram
raise NotFound, err_msg if 404 == status && result
raise Error, err_msg
end
%w(
answerInlineQuery
forwardMessage
getFile
getMe
getUpdates
getUserProfilePhotos
sendAudio
sendChatAction
sendDocument
sendLocation
sendMessage
sendPhoto
sendSticker
sendVideo
sendVoice
setWebhook
).each do |method|
define_method(method.underscore) { |*args| request(method, *args) }
end
# Endpoint for low-level request. For easy host highjacking & instrumentation.
# Params are not used directly but kept for instrumentation purpose.
# You probably don't want to use this method directly.
def http_request(uri, body)
client.post(uri, body)
end
def inspect
"#<#{self.class.name}##{object_id}(#{@username})>"
end
end
end
end

38
lib/telegram/bot/client/typed_response.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,38 @@
module Telegram
module Bot
class Client
# Actions with type-casted results. Install `telegram-bot-types` gem first.
module TypedResponse
{
getFile: :File,
getMe: :User,
getUpdates: [:Update],
getUserProfilePhotos: :UserProfilePhotos,
forwardMessage: :Message,
sendAudio: :Message,
sendDocument: :Message,
sendLocation: :Message,
sendMessage: :Message,
sendPhoto: :Message,
sendSticker: :Message,
sendVideo: :Message,
sendVoice: :Message,
}.each do |method, type|
next unless type
if type.is_a?(Array)
type_class = Types.const_get(type.first)
define_method(method.to_s.underscore) do |*args|
request(method, *args)['result'].map { |x| type_class.new(x) }
end
else
type_class = Types.const_get(type)
define_method(method.to_s.underscore) do |*args|
type_class.new request(method, *args)['result']
end
end
end
end
end
end
end

50
lib/telegram/bot/client_stub.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,50 @@
module Telegram
module Bot
# Stubbed client for tests. Saves all requests into #requests hash.
class ClientStub < Client
attr_reader :requests
module StubbedConstructor
def new(*args)
if self == ClientStub || !ClientStub.stub_all?
super
else
ClientStub.new(args[1])
end
end
end
class << self
# Makes all
def stub_all!(enabled = true)
Client.extend(StubbedConstructor) unless Client < StubbedConstructor
return @_stub_all = enabled unless block_given?
begin
old = @_stub_all
stub_all!(enabled)
yield
ensure
stub_all!(old)
end
end
def stub_all?
@_stub_all
end
end
def initialize(username = nil)
@username = username
reset
end
def reset
@requests = Hash.new { |h, k| h[k] = [] }
end
def request(action, body)
requests[action.to_sym] << body
end
end
end
end

53
lib/telegram/bot/config_methods.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,53 @@
require 'active_support/core_ext/hash/keys'
require 'active_support/core_ext/hash/transform_values'
module Telegram
module Bot
module ConfigMethods
# Overwrite config.
attr_writer :bots_config
# Keep this setting here, so we can avoid loading Bot::UpdatesPoller
# when polling is disabled.
attr_writer :bot_poller_mode
# It just tells routes helpers whether to add routed bots to
# Bot::UpdatesPoller, so their config will be available by bot key in
# Bot::UpdatesPoller.start.
def bot_poller_mode?
return @bot_poller_mode if defined?(@bot_poller_mode)
Rails.env.development? if defined?(Rails)
end
# Hash of bots made with bots_config.
def bots
@bots ||= bots_config.transform_values(&Client.method(:wrap))
end
# Default bot.
def bot
@bot ||= bots[:default]
end
# Returns config for .bots method. By default uses `telegram['bots']` section
# from `secrets.yml` merging `telegram['bot']` at `:default` key.
#
# Can be overwritten with .bots_config=
def bots_config
return @bots_config if @bots_config
telegram_config = Rails.application.secrets[:telegram]
(telegram_config['bots'] || {}).symbolize_keys.tap do |config|
default = telegram_config['bot']
config[:default] = default if default
end
end
# Resets all cached bots and their configs.
def reset_bots
@bots = nil
@bot = nil
@bots_config = nil
end
end
end
end

Просмотреть файл

@@ -3,7 +3,7 @@ require 'action_dispatch/http/mime_type'
require 'action_dispatch/middleware/params_parser' require 'action_dispatch/middleware/params_parser'
module Telegram module Telegram
class Bot module Bot
class Middleware class Middleware
attr_reader :bot, :controller attr_reader :bot, :controller
@@ -15,7 +15,7 @@ module Telegram
def call(env) def call(env)
update = env['action_dispatch.request.request_parameters'] update = env['action_dispatch.request.request_parameters']
controller.dispatch(bot, update) controller.dispatch(bot, update)
[200, {}, ''] [200, {}, ['']]
end end
def inspect def inspect

Просмотреть файл

@@ -1,7 +1,7 @@
require 'telegram/bot/routes_helper' require 'telegram/bot/routes_helper'
module Telegram module Telegram
class Bot module Bot
class Railtie < Rails::Railtie class Railtie < Rails::Railtie
config.telegram_updates_controller = ActiveSupport::OrderedOptions.new config.telegram_updates_controller = ActiveSupport::OrderedOptions.new
@@ -18,13 +18,15 @@ module Telegram
ActiveSupport.on_load('telegram.bot.updates_controller') do ActiveSupport.on_load('telegram.bot.updates_controller') do
self.logger = options.logger || Rails.logger self.logger = options.logger || Rails.logger
self.session_store = options.session_store || Rails.cache
end end
end end
initializer 'telegram.bot.updates_controller.add_ar_runtime' do initializer 'telegram.bot.updates_controller.add_ar_runtime' do
ActiveSupport.on_load('telegram.bot.updates_controller') do ActiveSupport.on_load('telegram.bot.updates_controller') do
if defined?(ActiveRecord::Railties::ControllerRuntime) if defined?(ActiveRecord::Railties::ControllerRuntime)
include ActiveRecord::Railties::ControllerRuntime prepend ActiveRecord::Railties::ControllerRuntime
extend ActiveRecord::Railties::ControllerRuntime::ClassMethods
end end
end end
end end

Просмотреть файл

@@ -1,7 +1,8 @@
require 'telegram/bot' require 'telegram/bot'
require 'active_support/core_ext/array/wrap'
module Telegram module Telegram
class Bot module Bot
module RoutesHelper module RoutesHelper
class << self class << self
# Returns route name for given bot. Result depends on `Telegram.bots`. # Returns route name for given bot. Result depends on `Telegram.bots`.
@@ -15,6 +16,12 @@ module Telegram
name && "#{name}_telegram_webhook" name && "#{name}_telegram_webhook"
end || 'telegram_webhook' end || 'telegram_webhook'
end end
# Replaces colon with underscore so rails don't treat it as
# route parameter.
def escape_token(token)
token.tr(':', '_')
end
end end
# # Create routes for all Telegram.bots to use same controller: # # Create routes for all Telegram.bots to use same controller:
@@ -35,21 +42,23 @@ module Telegram
# # You can override this options or specify others: # # You can override this options or specify others:
# telegram_webhooks TelegramController, as: :my_webhook # telegram_webhooks TelegramController, as: :my_webhook
# telegram_webhooks bot => [TelegramChatController, as: :chat_webhook], # telegram_webhooks bot => [TelegramChatController, as: :chat_webhook],
# other_bot => [TelegramAuctionController, # other_bot => TelegramAuctionController,
# admin_chat: TelegramAdminChatController
#
def telegram_webhooks(controllers, bots = nil, **options) def telegram_webhooks(controllers, bots = nil, **options)
unless controllers.is_a?(Hash) unless controllers.is_a?(Hash)
bots = bots ? Array.wrap(bots) : Telegram.bots.values bots = bots ? Array.wrap(bots) : Telegram.bots.values
controllers = Hash[bots.map { |x| [x, controllers] }] controllers = Hash[bots.map { |x| [x, controllers] }]
end end
controllers.each do |bot, controller| controllers.each do |bot, controller|
bot = Bot.wrap(bot) bot = Client.wrap(bot)
controller, bot_options = controller if controller.is_a?(Array) controller, bot_options = controller if controller.is_a?(Array)
params = { params = {
to: Middleware.new(bot, controller), to: Middleware.new(bot, controller),
as: RoutesHelper.route_name_for_bot(bot), as: RoutesHelper.route_name_for_bot(bot),
format: false, format: false,
}.merge!(options).merge!(bot_options || {}) }.merge!(options).merge!(bot_options || {})
post("telegram/#{bot.token}", params) post("telegram/#{RoutesHelper.escape_token bot.token}", params)
UpdatesPoller.add(bot, controller) if Telegram.bot_poller_mode? UpdatesPoller.add(bot, controller) if Telegram.bot_poller_mode?
end end
end end

Просмотреть файл

@@ -1,15 +1,79 @@
require 'abstract_controller' require 'abstract_controller'
require 'active_support/callbacks' require 'active_support/callbacks'
require 'active_support/version'
module Telegram module Telegram
class Bot module Bot
# Base class to create update processors. With callbacks, session and helpers.
#
# Define public methods for each command and they will be called when
# update has this command. Message is automatically parsed and
# words are passed as method arguments. Be sure to use default values and
# splat arguments in every action method to not get errors, when user
# sends command without necessary args / with extra args.
#
# def start(token = nil, *)
# if token
# # ...
# else
# # ...
# end
# end
#
# def help(*)
# reply_with :message, text:
# end
#
# To process plain text messages (without commands) or other updates just
# define public method with name of payload type. They will receive payload
# as an argument.
#
# def message(message)
# reply_with :message, text: "Echo: #{message['text']}"
# end
#
# def inline_query(query)
# answer_inline_query results_for_query(query), is_personal: true
# end
#
# # To process conflicting commands (`/message args`) just use `on_` prefix:
# def on_message(*args)
# # ...
# end
#
# To process update run:
#
# ControllerClass.dispatch(bot, update)
#
# There is also ability to run action without update:
#
# ControllerClass.new(bot, from: telegram_user, chat: telegram_chat).
# process(:help, *args)
#
class UpdatesController < AbstractController::Base class UpdatesController < AbstractController::Base
include AbstractController::Callbacks abstract!
include AbstractController::Translation
require 'telegram/bot/updates_controller/session'
require 'telegram/bot/updates_controller/log_subscriber' require 'telegram/bot/updates_controller/log_subscriber'
require 'telegram/bot/updates_controller/instrumentation' require 'telegram/bot/updates_controller/instrumentation'
autoload :MessageContext, 'telegram/bot/updates_controller/message_context'
include AbstractController::Callbacks
# Redefine callbacks with default terminator.
if ActiveSupport.gem_version >= Gem::Version.new('5')
define_callbacks :process_action,
skip_after_callbacks_if_terminated: true
else
define_callbacks :process_action,
terminator: ->(_, result) { result == false },
skip_after_callbacks_if_terminated: true
end
include AbstractController::Translation
prepend Instrumentation prepend Instrumentation
extend Session::ConfigMethods
autoload :TypedUpdate, 'telegram/bot/updates_controller/typed_update'
PAYLOAD_TYPES = %w( PAYLOAD_TYPES = %w(
message message
@@ -18,9 +82,9 @@ module Telegram
).freeze ).freeze
CMD_REGEX = %r{\A/([a-z\d_]{,31})(@(\S+))?(\s|$)}i CMD_REGEX = %r{\A/([a-z\d_]{,31})(@(\S+))?(\s|$)}i
CONFLICT_CMD_REGEX = Regexp.new("^(#{PAYLOAD_TYPES.join('|')}|\\d)") CONFLICT_CMD_REGEX = Regexp.new("^(#{PAYLOAD_TYPES.join('|')}|\\d)")
abstract!
class << self class << self
# Initialize controller and process update.
def dispatch(*args) def dispatch(*args)
new(*args).dispatch new(*args).dispatch
end end
@@ -43,7 +107,7 @@ module Telegram
match = text.match CMD_REGEX match = text.match CMD_REGEX
return unless match return unless match
return if match[3] && username != true && match[3] != username return if match[3] && username != true && match[3] != username
[match[1], text.split(' ').drop(1)] [match[1], text.split.drop(1)]
end end
end end
@@ -51,18 +115,39 @@ module Telegram
alias_method :command?, :is_command alias_method :command?, :is_command
delegate :username, to: :bot, prefix: true, allow_nil: true delegate :username, to: :bot, prefix: true, allow_nil: true
# Second argument can be either update object with hash access & string
# keys or Hash with `:from` or `:chat` to override this values and assume
# that update is nil.
def initialize(bot = nil, update = nil) def initialize(bot = nil, update = nil)
if update.is_a?(Hash) && (update.key?(:from) || update.key?(:chat))
options = update
update = nil
end
@_update = update @_update = update
@_bot = bot @_bot = bot
@_chat, @_from = options && options.values_at(:chat, :from)
payload_data = nil
update && PAYLOAD_TYPES.find do |type| update && PAYLOAD_TYPES.find do |type|
item = update[type] item = update[type]
next unless item payload_data = [item, type] if item
@_payload = item
@_payload_type = type
end end
@_payload, @_payload_type = payload_data
end end
# Accessor to `'chat'` field of payload. Can be overriden with `chat` option
# for #initialize.
def chat
@_chat || payload && payload['chat']
end
# Accessor to `'from'` field of payload. Can be overriden with `from` option
# for #initialize.
def from
@_from || payload && payload['from']
end
# Processes current update.
def dispatch def dispatch
@_is_command, action, args = action_for_payload @_is_command, action, args = action_for_payload
process(action, *args) process(action, *args)
@@ -86,19 +171,32 @@ module Telegram
def action_missing(*) def action_missing(*)
end end
%w(chat from).each do |field| # Helper to call bot's `send_#{type}` method with already set `chat_id` and
define_method(field) { payload[field] } # `reply_to_message_id`:
end #
# reply_with :message, text: 'Hello!'
# reply_with :message, text: '__Hello!__', parse_mode: :Markdown
# reply_with :photo, photo: File.open(photo_to_send), caption: "It's incredible!"
def reply_with(type, params) def reply_with(type, params)
method = "send_#{type}" method = "send_#{type}"
chat = self.chat
payload = self.payload
params = params.merge( params = params.merge(
chat_id: chat['id'], chat_id: (chat && chat['id'] or raise 'Can not reply_with when chat is not present'),
reply_to_message: payload['message_id'], reply_to_message: payload && payload['message_id'],
) )
bot.public_send(method, params) bot.public_send(method, params)
end end
# Same as reply_with, but for inline queries.
def answer_inline_query(results, params = {})
params = params.merge(
inline_query_id: payload['id'],
results: results,
)
bot.answer_inline_query(params)
end
ActiveSupport.run_load_hooks('telegram.bot.updates_controller', self) ActiveSupport.run_load_hooks('telegram.bot.updates_controller', self)
end end
end end

Просмотреть файл

@@ -1,5 +1,5 @@
module Telegram module Telegram
class Bot module Bot
class UpdatesController class UpdatesController
# Most methods are taken from ActionController::Instrumentation, # Most methods are taken from ActionController::Instrumentation,
# some are slightly modified. # some are slightly modified.

Просмотреть файл

@@ -1,7 +1,7 @@
require 'active_support/log_subscriber' require 'active_support/log_subscriber'
module Telegram module Telegram
class Bot module Bot
class UpdatesController class UpdatesController
class LogSubscriber < ActiveSupport::LogSubscriber class LogSubscriber < ActiveSupport::LogSubscriber
def start_processing(event) def start_processing(event)

Просмотреть файл

@@ -0,0 +1,102 @@
module Telegram
module Bot
class UpdatesController
# Allows to store context in session and treat next message according to this context.
module MessageContext
extend ActiveSupport::Concern
include Session
included do
# As we use before_action context is cleared anyway,
# no matter we used it or not.
before_action :fetch_context
singleton_class.send :attr_reader, :context_handlers, :context_to_action
@context_handlers = {}
end
module ClassMethods
# Registers handler for context.
#
# context_handler :rename do |message|
# resource.update!(name: message['text'])
# end
#
# # To run other action with all the callbacks:
# context_handler :rename do |message|
# process(:rename, *m['text'].split)
# end
#
# # Or just
# context_handler :rename, :your_action_to_call
# context_handler :rename # to call :rename
#
# # For messages without context use this instead of `message` method:
# context_handle do |message|
# end
#
def context_handler(context = nil, action = nil, &block)
context &&= context.to_sym
context_handlers[context] = block || action || context
end
# Use it to use context value as action name for all contexts
# which miss handlers.
# For security reasons it supports only action methods and will
# raise AbstractController::ActionNotFound if context is invalid.
def context_to_action!
@context_to_action = true
end
end
# Finds handler for current context and processes message with it.
def message(message)
handler = handler_for_context
return unless handler
if handler.respond_to?(:call)
instance_exec(message, &handler)
else
process(handler, *message['text'].split)
end
end
# Action to clear context.
def cancel
# Context is already cleared in before_action
end
private
# Context is read from the session to treat messages
# according to previous request.
attr_reader :context
# Fetches and removes context from session.
def fetch_context
val = session.delete(:context)
@context = val && val.to_sym
true # TODO: remove in Rails 5.0
end
# Save context for the next request.
def save_context(context)
session[:context] = context
end
def handler_for_context
self.class.context_handlers[context] || self.class.context_to_action && begin
action_name = context.to_s
unless action_method?(action_name)
raise AbstractController::ActionNotFound,
"The action '#{action_name}' could not be set from context " \
"for #{self.class.name}. " \
'context_to_action! supports only action methods for security reasons. ' \
'If you need to call this action use context_handler for it.'
end
action_name
end
end
end
end
end
end

Просмотреть файл

@@ -0,0 +1,37 @@
require 'telegram/bot/updates_controller/testing'
RSpec.shared_context 'telegram/bot/updates_controller' do
let(:controller_class) { described_class }
let(:controller) do
controller_class.new(bot, update).tap do |x|
x.extend Telegram::Bot::UpdatesController::Testing
end
end
let(:update) { build_update(payload_type, payload) }
let(:payload_type) { :some_type }
let(:payload) { double(:payload) }
let(:bot) { Telegram::Bot::ClientStub.new(bot_name) }
let(:bot_name) { 'bot' }
let(:session) { controller.send(:session) }
def dispatch(bot = self.bot, update = self.update)
controller.dispatch_again(bot, update)
end
def dispatch_message(text, options = {})
update = build_update :message, options.merge(text: text)
dispatch bot, update
end
def build_update(type, content)
deep_stringify type => content
end
def deep_stringify(input)
case input
when Array then input.map(&method(__callee__))
when Hash then input.transform_keys(&:to_s).transform_values(&method(__callee__))
else input
end
end
end

Просмотреть файл

@@ -0,0 +1,76 @@
require 'rack/session/abstract/id'
require 'active_support/cache'
module Telegram
module Bot
class UpdatesController
# Add functionality to store data between requests.
module Session
extend ActiveSupport::Concern
def process_action(*)
super
ensure
session.commit
end
protected
def session
@_session ||= SessionHash.new(self.class.session_store, session_key)
end
def session_key
"#{bot.username}:#{from ? "from:#{from['id']}" : "chat:#{chat['id']}"}"
end
# Rack::Session::Abstract::SessionHash is taken to provide lazy loading.
# All methods that access store are overriden to support
# ActiveSupport::Cache::Store stores.
class SessionHash < Rack::Session::Abstract::SessionHash
attr_reader :id
def initialize(store, id)
@store = store
@id = id
end
def destroy
clear
@store.delete(id)
end
def exists?
return @exists if defined?(@exists)
@data = {}
@exists = @store.exist? id
end
def load!
session = @store.read(id)
@data = session ? stringify_keys(session) : {}
@loaded = true
end
def commit
return unless loaded?
data = to_hash.delete_if { |_, v| v.nil? }
@store.write(id, data)
end
end
module ConfigMethods
delegate :session_store, to: :config
def session_store=(store)
config.session_store = ActiveSupport::Cache.lookup_store(store)
end
def use_session!
include Session
end
end
end
end
end
end

Просмотреть файл

@@ -0,0 +1,47 @@
module Telegram
module Bot
class UpdatesController
module Testing
IVARS_TO_KEEP = %i(@_session).freeze
# Perform multiple dispatches on same instance.
def dispatch_again(bot = nil, update = nil)
recycle!
initialize(bot, update)
dispatch
end
# Cleans controller between dispatches.
# Seems like there is nothing to clean between requests for now:
# everything will be rewriten with #initialize.
#
# With `full` set to `true` it'll clear all cached instance variables.
def recycle!(full = false)
return unless full
(instance_variables - IVARS_TO_KEEP).each do |ivar|
remove_instance_variable(ivar)
end
end
protected
# Stubs session.
def session
@_session ||= Testing::SessionHash.new
end
class SessionHash < Session::SessionHash
def initialize
@data = {}
@loaded = true
@exists = true
end
alias_method :destroy, :clear
alias_method :load!, :id
alias_method :commit, :id
end
end
end
end
end

Просмотреть файл

@@ -0,0 +1,14 @@
module Telegram
module Bot
class UpdatesController
# Include this module to type cast update to Virtus model
# using `telegram-bot-types` gem (install this gem first).
module TypedUpdate
def initialize(bot = nil, update = nil)
update = Types::Update.new(update) if update && !update.is_a?(Types::Update)
super
end
end
end
end
end

Просмотреть файл

@@ -1,5 +1,5 @@
module Telegram module Telegram
class Bot module Bot
# Supposed to be used in development environments only. # Supposed to be used in development environments only.
class UpdatesPoller class UpdatesPoller
class << self class << self
@@ -15,7 +15,7 @@ module Telegram
end end
def start(bot_id, controller = nil) def start(bot_id, controller = nil)
bot = bot_id.is_a?(Symbol) ? Telegram.bots[bot_id] : Bot.wrap(bot_id) bot = bot_id.is_a?(Symbol) ? Telegram.bots[bot_id] : Client.wrap(bot_id)
instance = controller ? new(bot, controller) : instances[bot] instance = controller ? new(bot, controller) : instances[bot]
raise "Poller not found for #{bot_id.inspect}" unless instance raise "Poller not found for #{bot_id.inspect}" unless instance
instance.start instance.start

Просмотреть файл

@@ -1,6 +1,6 @@
module Telegram module Telegram
class Bot module Bot
VERSION = '0.3.0'.freeze VERSION = '0.5.0'.freeze
def self.gem_version def self.gem_version
Gem::Version.new VERSION Gem::Version.new VERSION

Просмотреть файл

@@ -1,41 +0,0 @@
module Telegram
module Bottable
# Overwrite config.
attr_writer :bots_config
# Keep this setting here, so we can avoid loading Bot::UpdatesPoller
# when polling is disabled.
attr_writer :bot_poller_mode
# It just tells routes helpers whether to add routed bots to
# Bot::UpdatesPoller, so their config will be available by bot key in
# Bot::UpdatesPoller.start.
def bot_poller_mode?
return @bot_poller_mode if defined?(@bot_poller_mode)
Rails.env.development? if defined?(Rails)
end
# Hash of bots made with bots_config.
def bots
@bots ||= bots_config.transform_values(&Bot.method(:wrap))
end
# Default bot.
def bot
@bot ||= bots[:default]
end
# Returns config for .bots method. By default uses `telegram['bots']` section
# from `secrets.yml` merging `telegram['bot']` at `:default` key.
#
# Can be overwritten with .bots_config=
def bots_config
return @bots_config if @bots_config
telegram_config = Rails.application.secrets[:telegram]
(telegram_config['bots'] || {}).symbolize_keys.tap do |config|
default = telegram_config['bot']
config[:default] = default if default
end
end
end
end

Просмотреть файл

@@ -14,6 +14,8 @@ GEM_ROOT = Pathname.new File.expand_path('../..', __FILE__)
$LOAD_PATH.unshift GEM_ROOT.join('lib') $LOAD_PATH.unshift GEM_ROOT.join('lib')
require 'telegram/bot' require 'telegram/bot'
require 'telegram/bot/updates_controller/rspec_helpers'
require 'telegram/bot/types'
RSpec.configure do |config| RSpec.configure do |config|
config.expect_with :rspec do |expectations| config.expect_with :rspec do |expectations|

Просмотреть файл

@@ -0,0 +1,32 @@
RSpec.describe Telegram::Bot::Client::TypedResponse do
let(:bot) { Telegram::Bot::Client.new('token').tap { |x| x.extend described_class } }
describe '#get_me' do
subject { bot.get_me }
before { expect(bot).to receive(:request).with(:getMe) { response } }
let(:response) { {'ok' => true, 'result' => {'id' => user_id}} }
let(:user_id) { 123 }
it { should be_instance_of Telegram::Bot::Types::User }
its(:id) { should eq user_id }
context 'on error' do
let(:response) { raise Telegram::Bot::Error }
it { expect { subject }.to raise_error Telegram::Bot::Error }
end
end
describe '#get_updates' do
subject { bot.get_updates }
before { expect(bot).to receive(:request).with(:getUpdates) { response } }
let(:response) { {'ok' => true, 'result' => [{'update_id' => update_id}]} }
let(:update_id) { 123 }
it { should be_instance_of Array }
its(:first) { should be_instance_of Telegram::Bot::Types::Update }
its('first.update_id') { should eq update_id }
context 'on error' do
let(:response) { raise Telegram::Bot::Error }
it { expect { subject }.to raise_error Telegram::Bot::Error }
end
end
end

77
spec/telegram/bot/client_spec.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,77 @@
RSpec.describe Telegram::Bot::Client do
describe '.wrap' do
subject { described_class.wrap(input) }
let(:result) { double(:result) }
let(:token) { 'token' }
let(:username) { 'username' }
let(:instance) { described_class.new 'token' }
context 'when input is a string' do
let(:input) { token }
it 'treats string as token' do
expect(described_class).to receive(:new).with(token) { result }
should eq result
end
end
context 'when input is a hash' do
let(:input) { {token: token, username: username, ignore: :ignore} }
it 'extracts token & username' do
expect(described_class).to receive(:new).with(token, username) { result }
should eq result
end
end
context 'when input is an instance of described_class' do
let!(:input) { instance }
it 'returns input' do
expect(described_class).to_not receive(:new)
should eq input
end
end
context 'when input is a Symbol' do
let(:input) { :bot_1 }
before { allow(Telegram).to receive(:bots) { {bot_1: instance} } }
it { should eq Telegram.bots[:bot_1] }
context 'and there is no such bot' do
let(:input) { :invalid }
it { expect { subject }.to raise_error(/not configured/) }
end
end
context 'when input is an array' do
let!(:input) { ['other_token', instance, token: token, username: username] }
let(:result_2) { double(:result_2) }
it 'calls wrap for every element' do
expect(described_class).to receive(:new).with('other_token') { result }
expect(described_class).to receive(:new).with(token, username) { result_2 }
should eq [result, instance, result_2]
end
end
end
describe '.prepare_body' do
subject { described_class.prepare_body(input) }
context 'when plain hash is given' do
let(:input) { {a: 1, b: '2', c: [1, 2]} }
it { should eq input }
end
context 'when nested hash is given' do
let(:input) { {a: 1, b: '2', c: [1, 2], d: {a: 1}, e: {b: []}} }
it 'encodes nested hashes to json' do
expected = input.dup
%i(d e).each { |x| expected[x] = expected[x].to_json }
should eq expected
end
end
end
end

38
spec/telegram/bot/client_stub_spec.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,38 @@
RSpec.describe Telegram::Bot::ClientStub do
describe '#stub_all!' do
let(:client) { Telegram::Bot::Client.new('token', 'bot_name') }
let(:clients) { Telegram::Bot::Client.wrap(['token', token: 'token2']) }
shared_examples 'constructors' do |expected_class|
it 'makes Client.new return ClientStub' do
expect(client).to be_instance_of expected_class
expect(client.username).to eq 'bot_name'
end
it 'makes Client.wrap raturn ClientStub' do
expect(clients).to contain_exactly instance_of(expected_class),
instance_of(expected_class)
end
end
context 'when not used' do
include_examples 'constructors', Telegram::Bot::Client
end
context 'when enabled' do
around { |ex| described_class.stub_all! { ex.run } }
include_examples 'constructors', Telegram::Bot::ClientStub
end
context 'when redisabled' do
around do |ex|
described_class.stub_all! do
described_class.stub_all!(false) do
ex.run
end
end
end
include_examples 'constructors', Telegram::Bot::Client
end
end
end

Просмотреть файл

@@ -13,6 +13,6 @@ RSpec.describe Telegram::Bot::Middleware do
subject subject
end end
it { should eq [200, {}, ''] } it { should eq [200, {}, ['']] }
end end
end end

Просмотреть файл

@@ -1,10 +1,14 @@
require 'telegram/bot/routes_helper' require 'telegram/bot/routes_helper'
RSpec.describe Telegram::Bot::RoutesHelper do RSpec.describe Telegram::Bot::RoutesHelper do
let(:bot) { Telegram::Bot.new('bot_token') } let(:bot) { create_bot('bot_token') }
let(:other_bot) { Telegram::Bot.new('other_token') } let(:other_bot) { create_bot('other_token') }
let(:bots) { {default: bot, other: other_bot} } let(:bots) { {default: bot, other: other_bot} }
def create_bot(*args)
Telegram::Bot::Client.new(*args)
end
describe '.route_name_for_bot' do describe '.route_name_for_bot' do
subject { described_class.route_name_for_bot(input) } subject { described_class.route_name_for_bot(input) }
before { expect(Telegram).to receive(:bots) { bots } } before { expect(Telegram).to receive(:bots) { bots } }
@@ -74,6 +78,7 @@ RSpec.describe Telegram::Bot::RoutesHelper do
{ {
bot => controller, bot => controller,
'custom_token' => [other_controller, as: :custom_route, option: :other_val], 'custom_token' => [other_controller, as: :custom_route, option: :other_val],
other: controller,
}, },
option: :val, option: :val,
] ]
@@ -82,11 +87,12 @@ RSpec.describe Telegram::Bot::RoutesHelper do
it 'creates routes for every bot and its controller' do it 'creates routes for every bot and its controller' do
assert_routes [bot, controller, 'default_telegram_webhook', option: :val], assert_routes [bot, controller, 'default_telegram_webhook', option: :val],
[ [
Telegram::Bot.new('custom_token'), create_bot('custom_token'),
other_controller, other_controller,
:custom_route, :custom_route,
option: :other_val, option: :other_val,
] ],
[Telegram.bots[:other], controller, 'other_telegram_webhook', option: :val]
end end
end end
@@ -101,12 +107,12 @@ RSpec.describe Telegram::Bot::RoutesHelper do
it 'creates routes for every created bot and controller' do it 'creates routes for every created bot and controller' do
assert_routes [ assert_routes [
Telegram::Bot.new('custom_token'), create_bot('custom_token'),
controller, controller,
'telegram_webhook', 'telegram_webhook',
option: :val, option: :val,
], [ ], [
Telegram::Bot.new(bot.token, 'new_name'), create_bot(bot.token, 'new_name'),
controller, controller,
'telegram_webhook', 'telegram_webhook',
option: :val, option: :val,

Просмотреть файл

@@ -0,0 +1,109 @@
RSpec.describe Telegram::Bot::UpdatesController::MessageContext do
include_context 'telegram/bot/updates_controller'
let(:controller_class) do
described_class = self.described_class
Class.new(Telegram::Bot::UpdatesController) do
include described_class
attr_accessor :filter_done
before_action only: :redirect do
self.filter_done = true
end
context_handler do |*args|
[:no_context, *args]
end
context_handler :block do |*args|
[:block_result, *args]
end
context_handler :redirect
context_handler :other_redirect, :redirect
def redirect(*args)
[:method_result, *args]
end
def action(*args)
[:action_result, *args]
end
private
def not_action
raise 'Should not be called!'
end
end
end
describe '#message' do
subject { -> { dispatch } }
let(:payload_type) { :message }
let(:payload) { {'text' => text} }
let(:text) { 'asd qwe zxc' }
context 'when context is not set' do
its(:call) { should eq [:no_context, payload] }
it { should_not change(controller, :filter_done) }
it { should_not change { session[:context] } }
end
context 'when context is handled by block' do
before { session[:context] = :block }
its(:call) { should eq [:block_result, payload] }
it { should_not change(controller, :filter_done) }
it { should change { session[:context] }.to nil }
end
context 'when context is handled by short redirect' do
before { session[:context] = :redirect }
its(:call) { should eq [:method_result, *text.split] }
it { should change(controller, :filter_done).to true }
it { should change { session[:context] }.to nil }
end
context 'when context is handled by custom redirect' do
before { session[:context] = :other_redirect }
its(:call) { should eq [:method_result, *text.split] }
it { should change(controller, :filter_done).to true }
it { should change { session[:context] }.to nil }
end
context 'when context is action`s name but not mapped' do
before { session[:context] = :action }
its(:call) { should eq nil }
it { should_not change(controller, :filter_done) }
it { should change { session[:context] }.to nil }
end
context 'when context_to_action is true' do
before { controller_class.context_to_action! }
context 'when context is action`s name but not mapped' do
before { session[:context] = :action }
its(:call) { should eq [:action_result, *text.split] }
it { should_not change(controller, :filter_done) }
it { should change { session[:context] }.to nil }
end
context 'when context is invalid' do
before { session[:context] = :invalid }
it 'raises error and clears context' do
expect do
should raise_error AbstractController::ActionNotFound
end.to change { session[:context] }.to nil
end
end
context 'when context is private method`s name' do
before { session[:context] = :not_action }
it 'raises error and clears context' do
expect do
should raise_error AbstractController::ActionNotFound
end.to change { session[:context] }.to nil
end
end
end
end
end

Просмотреть файл

@@ -0,0 +1,43 @@
RSpec.describe Telegram::Bot::UpdatesController::Session do
include_context 'telegram/bot/updates_controller'
let(:controller_class) do
described_class = self.described_class
Class.new(Telegram::Bot::UpdatesController) do
include described_class
end
end
describe '.action_methods' do
subject { controller_class.action_methods }
it { should be_empty }
end
describe '.dispatch' do
subject { ->(*args) { controller_class.dispatch(*args) } }
let(:other_bot) { double(username: 'otherBot') }
before do
controller_class.class_eval do
self.session_store = :memory_store
def write(text)
session[:text] = text
end
def read
session[:text]
end
end
end
def build_message(text, from)
{'message' => {'text' => text, 'from' => from.stringify_keys}}
end
it 'stores session between requests' do
subject.call(bot, build_message('/write test', id: 1))
expect(subject.call(bot, build_message('/read', id: 1))).to eq 'test'
expect(subject.call(bot, build_message('/read', id: 2))).to eq nil
expect(subject.call(other_bot, build_message('/read', id: 1))).to eq nil
end
end
end

Просмотреть файл

@@ -0,0 +1,27 @@
RSpec.describe Telegram::Bot::UpdatesController::Testing do
include_context 'telegram/bot/updates_controller'
let(:controller_class) do
Class.new(Telegram::Bot::UpdatesController) do
attr_accessor :ivar
end
end
describe '#recycle!' do
subject { -> { controller.recycle!(full) } }
before do
controller.ivar = :ival
session[:key] = 'sval'
end
let(:full) {}
it { should_not change(controller, :ivar).from :ival }
it { should_not change { controller.send(:session)[:key] }.from 'sval' }
context 'when full is true' do
let(:full) { true }
it { should change(controller, :ivar).from(:ival).to nil }
it { should change { controller.instance_variable_defined?(:@ivar) }.to false }
it { should_not change { controller.send(:session)[:key] }.from 'sval' }
end
end
end

Просмотреть файл

@@ -0,0 +1,26 @@
RSpec.describe Telegram::Bot::UpdatesController::TypedUpdate do
include_context 'telegram/bot/updates_controller'
let(:controller_class) do
described_class = self.described_class
Class.new(Telegram::Bot::UpdatesController) do
include described_class
end
end
context 'when `update` is a virtus model' do
subject { controller }
%w(
message
inline_query
chosen_inline_result
).each do |type|
context "with #{type}" do
type_class = Telegram::Bot::Types.const_get(type.camelize)
let(:payload_type) { type }
let(:payload) { {} }
its(:payload_type) { should eq payload_type }
its(:payload) { should be_instance_of type_class }
end
end
end
end

Просмотреть файл

@@ -1,10 +1,5 @@
RSpec.describe Telegram::Bot::UpdatesController do RSpec.describe Telegram::Bot::UpdatesController do
let(:instance) { described_class.new(bot, update) } include_context 'telegram/bot/updates_controller'
let(:update) { {payload_type => payload} }
let(:payload_type) { 'some_type' }
let(:payload) { double(:payload) }
let(:bot) { double(username: bot_name) }
let(:bot_name) { 'bot' }
let(:other_bot_name) { 'other_bot' } let(:other_bot_name) { 'other_bot' }
describe '.action_for_command' do describe '.action_for_command' do
@@ -83,7 +78,7 @@ RSpec.describe Telegram::Bot::UpdatesController do
end end
describe '#action_for_payload' do describe '#action_for_payload' do
subject { instance.action_for_payload } subject { controller.action_for_payload }
(described_class::PAYLOAD_TYPES - %w(message)).each do |type| (described_class::PAYLOAD_TYPES - %w(message)).each do |type|
context "when payload is #{type}" do context "when payload is #{type}" do
@@ -117,8 +112,26 @@ RSpec.describe Telegram::Bot::UpdatesController do
end end
end end
context 'when `update` is a virtus model' do
subject { controller }
let(:update) { Telegram::Bot::Types::Update.new(super()) }
%w(
message
inline_query
chosen_inline_result
).each do |type|
context "with #{type}" do
type_class = Telegram::Bot::Types.const_get(type.camelize)
let(:payload_type) { type }
let(:payload) { {} }
its(:payload_type) { should eq payload_type }
its(:payload) { should be_instance_of type_class }
end
end
end
describe '#bot_username' do describe '#bot_username' do
subject { instance.bot_username } subject { controller.bot_username }
context 'when bot is not set' do context 'when bot is not set' do
let(:bot) {} let(:bot) {}
@@ -130,4 +143,159 @@ RSpec.describe Telegram::Bot::UpdatesController do
it { should eq bot.username } it { should eq bot.username }
end end
end end
describe '#process' do
subject { -> { controller.process(:action, *args) } }
let(:args) { [:arg1, :arg2] }
let(:controller_class) do
Class.new(described_class) do
attr_reader :acted, :hooked
def action(*args)
@acted = true
[from, chat, args]
end
end
end
context 'when action is protected' do
before { controller_class.send :protected, :action }
its(:call) { should eq nil }
context 'when action_missing defined' do
before do
controller.class_eval do
protected
def action_missing(*args)
args
end
end
end
its(:call) { should eq ['action', *args] }
end
end
context 'when callbacks are defined' do
before do
controller_class.class_eval do
before_action :hook, only: :action
attr_reader :hooked
private
def hook
@hooked = true
end
end
end
it { should change(controller, :hooked).to true }
it { should change(controller, :acted).to true }
its(:call) { should eq [nil, nil, args] }
context 'when callback returns false' do
before do
controller_class.prepend(Module.new do
def hook
super
false
end
end)
end
it { should change(controller, :hooked).to true }
it { should_not change(controller, :acted).from nil }
its(:call) { should eq false }
end
end
context 'when initialized without update' do
let(:controller) { controller_class.new(bot, from: from, chat: chat) }
let(:from) { {'id' => 'user_id'} }
let(:chat) { {'id' => 'chat_id'} }
its(:call) { should eq [from, chat, args] }
end
end
describe '#initialize' do
subject { controller }
let(:payload_type) { 'message' }
let(:payload) { deep_stringify(chat: chat, from: from) }
let(:chat) { double(:chat) }
let(:from) { double(:from) }
def self.with_reinitialize(&block)
instance_eval(&block)
context 'when re-initialized' do
let(:controller) do
described_class.new(double(:other_bot), build_update(:message,
text: 'original message',
from: double(:original_from),
chat: double(:original_chat),
)).tap { |x| x.send(:initialize, bot, update) }
end
instance_eval(&block)
end
end
context 'when update is given' do
with_reinitialize do
its(:bot) { should eq bot }
its(:update) { should eq update }
its(:payload) { should eq payload }
its(:payload_type) { should eq payload_type }
its(:from) { should eq from }
its(:chat) { should eq chat }
end
end
context 'when options hash is given' do
let(:update) { {from: from, chat: chat} }
with_reinitialize do
its(:bot) { should eq bot }
its(:update) { should eq nil }
its(:payload) { should eq nil }
its(:payload_type) { should eq nil }
its(:from) { should eq from }
its(:chat) { should eq chat }
end
end
end
describe '#reply_with' do
subject { controller.reply_with type, params }
let(:params) { {arg: 1, 'other_arg' => 2} }
let(:type) { :photo }
let(:result) { double(:result) }
let(:payload_type) { :message }
let(:payload) { {message_id: double(:message_id)} }
let(:chat) { {'id' => double(:chat_id)} }
it 'sets chat_id & reply_to_message' do
expect(controller).to receive(:chat) { chat }
expect(bot).to receive("send_#{type}").with(params.merge(
chat_id: chat['id'],
reply_to_message: payload[:message_id],
)) { result }
should eq result
end
context 'when chat is missing' do
let(:payload_type) { :some_type }
it { expect { subject }.to raise_error(/chat/) }
end
context 'when update is not set' do
let(:update) { {chat: chat} }
it 'sets chat_id & reply_to_message' do
expect(bot).to receive("send_#{type}").with(params.merge(
chat_id: chat['id'],
reply_to_message: nil,
)) { result }
should eq result
end
end
end
end end

Просмотреть файл

@@ -1,51 +1,5 @@
RSpec.describe Telegram::Bot do RSpec.describe Telegram::Bot do
it 'has a version number' do it 'has a version number' do
expect(Telegram::Bot::VERSION).not_to be nil expect(described_class::VERSION).not_to be nil
end
describe '.wrap' do
subject { described_class.wrap(input) }
let(:result) { double(:result) }
let(:token) { 'token' }
let(:username) { 'username' }
let(:instance) { described_class.new 'token' }
context 'when input is a string' do
let(:input) { token }
it 'treats string as token' do
expect(described_class).to receive(:new).with(token) { result }
should eq result
end
end
context 'when input is a hash' do
let(:input) { {token: token, username: username, ignore: :ignore} }
it 'extracts token & username' do
expect(described_class).to receive(:new).with(token, username) { result }
should eq result
end
end
context 'when input is an instance of described_class' do
let!(:input) { instance }
it 'returns input' do
expect(described_class).to_not receive(:new)
should eq input
end
end
context 'when input is an array' do
let!(:input) { ['other_token', instance, token: token, username: username] }
let(:result_2) { double(:result_2) }
it 'calls wrap for every element' do
expect(described_class).to receive(:new).with('other_token') { result }
expect(described_class).to receive(:new).with(token, username) { result_2 }
should eq [result, instance, result_2]
end
end
end end
end end

Просмотреть файл

@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
spec.email = ['melentievm@gmail.com'] spec.email = ['melentievm@gmail.com']
spec.summary = 'Library for building Telegram Bots with Rails integration' spec.summary = 'Library for building Telegram Bots with Rails integration'
spec.homepage = 'https://github.com/printercu/telegram-bot' spec.homepage = 'https://github.com/telegram-bot-rb/telegram-bot'
spec.license = 'MIT' spec.license = 'MIT'
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) } spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) }