diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc92d4..2893914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +- Async API requests. - One more description for StaleChat error. - edit_message_* methods. - API methods from 2016-10-03 update diff --git a/README.md b/README.md index 7d04efd..957c1be 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Package contains: - Middleware and routes helpers for production env. - Poller with automatic source-reloader for development env. - Rake tasks to update webhook urls. +- __[Async mode](#async-mode)__ for Telegram and/or Botan API. + Let the queue adapter handle network errors! Here is sample [telegram_bot_app](https://github.com/telegram-bot-rb/telegram_bot_app) with session, keyboards and inline queries. @@ -354,11 +356,11 @@ or just add `botan` key in `secrets.yml`: Access to Botan client with `bot.botan`. Use `bot.botan.track(event, uid, payload)` to track events. -There are some helpers for controllers in `Telegram::Bot::UpdatesController::Botan`: +There are some helpers for controllers in `Telegram::Bot::Botan::ControllerHelpers`: ```ruby class Telegram::WebhookController < Telegram::Bot::UpdatesController - include Telegram::Bot::UpdatesController::Botan + include Telegram::Bot::Botan::ControllerHelpers # This will track with event: action_name & data: payload before_action :botan_track_action @@ -375,6 +377,33 @@ end There is no stubbing for botan clients, so don't set botan token in tests. +### Async mode + +There is built in support for async requests using ActiveJob. Without Rails +you can implement your own worker class to handle such requests. This allows: + +- Process updates very fast, without waiting for telegram and botan responses. +- Handle and retry network and other errors with queue adapter. +- ??? + +Instead of performing request instantly client serializes it, pushes to queue, +and immediately return control back. The job is then fetched with a worker +and real API request is performed. And this all is absolutely transparent for the app. + +To enable this mode add `async: true` to bot's and botan's config. +For more information and custom configuration check out +[docs](http://www.rubydoc.info/github/telegram-bot-rb/telegram-bot/master/Telegram/Bot/Async) or +[source](https://github.com/telegram-bot-rb/telegram-bot/blob/master/lib/telegram/bot/async.rb). + +Be aware of some limitations: + +- Client will not return API response. +- Sending files is not available in async mode [now], + because them can not be serialized. + +To disable async mode for the block of code use `bot.async(false) { bot.send_photo }`. +Yes, it's threadsafe too. + ## Development After checking out the repo, run `bin/setup` to install dependencies. diff --git a/lib/telegram/bot.rb b/lib/telegram/bot.rb index 69557e0..72f2400 100644 --- a/lib/telegram/bot.rb +++ b/lib/telegram/bot.rb @@ -27,9 +27,12 @@ module Telegram end end + autoload :Async, 'telegram/bot/async' autoload :Botan, 'telegram/bot/botan' autoload :Client, 'telegram/bot/client' autoload :ClientStub, 'telegram/bot/client_stub' + autoload :DebugClient, 'telegram/bot/debug_client' + autoload :Initializers, 'telegram/bot/initializers' autoload :Middleware, 'telegram/bot/middleware' autoload :UpdatesController, 'telegram/bot/updates_controller' autoload :UpdatesPoller, 'telegram/bot/updates_poller' diff --git a/lib/telegram/bot/async.rb b/lib/telegram/bot/async.rb new file mode 100644 index 0000000..f97d862 --- /dev/null +++ b/lib/telegram/bot/async.rb @@ -0,0 +1,162 @@ +module Telegram + module Bot + # Telegram & Botan clients can perform requests in async way with + # any job adapter (ActiveJob by default). Using Rails you don't need any + # additional configuration. However you may want to enable async requests + # by default with `async: true` in `secrets.yml`. Botan client doesn't inherit + # async setting from client and must be configured separately. + # + # telegram: + # bots: + # chat_async: + # token: secret + # async: true # enable async mode for client + # botan: botan_token # in this way botan will not be async + # botan: # in this way - it's in async mode + # token: botan_token + # async: true + # + # Without Rails To start using async requests + # initialize client with `id` kwarg and make sure the client is + # accessible via `Teletgram.bots[id]` in job worker. Or just use + # `Telegram.bots_config=` for configuration. + # + # Being in async mode `#request` enqueues job instead to perform + # http request instead of performing it directly. + # Async behavior is controlled with `#async=` writer + # and can be enabled/disabled for the block with `#async`: + # + # client = Telegram::Bot::Client.new(**config, async: true) + # client.send_message(message) + # client.async(false) { client.send_message(other_one) } + # + # `#async=` sets global value for all threads, + # while `#async(val, &block)` is thread-safe. + # + # It can be set with custom job class or classname. By default it defines + # job classes for every client class, inherited from ApplicationRecord, which + # can be accessed via `.default_async_job`. You can integrate it with any + # other job provider by defining a class with `.perform_later(bot_id, *args)` + # method. See Async::Job for implemetation. + module Async + # Used to track missing key in a hash in local variable. + MISSING_VALUE = Object.new.freeze + + module Job + class << self + def included(base) + base.singleton_class.send :attr_accessor, :client_class + end + end + + def perform(client_id, *args) + client = self.class.client_class.wrap(client_id.to_sym) + client.async(false) { client.request(*args) } + end + end + + module ClassMethods + def default_async_job + @default_async_job ||= begin + begin + ApplicationJob + rescue NameError + raise 'Define ApplicationJob class or setup #async= with custom job class' + end + klass = Class.new(ApplicationJob) { include Job } + klass.client_class = self + const_set(:AsyncJob, klass) + end + end + + # This is used in specs. + def default_async_job=(val) + @default_async_job = val + remove_const(:AsyncJob) if const_defined?(:AsyncJob, false) + end + + # Prepares argments for async job. ActiveJob doesn't support + # Symbol in argumens. Also we can encode json bodies only once here, + # so it would not be unnecessarily serialized-deserialized. + # + # This is stub method, which returns input. Every client class + # must prepare args itself. + def prepare_async_args(*args) + args + end + + # Returns default_async_job if `true` is given, + # treats String as a constant name, or bypasses any other values. + def prepare_async_val(val) + case val + when true then default_async_job + when String then Object.const_get(val) + else val + end + end + end + + class << self + def prepended(base) + base.extend(ClassMethods) + end + + # Transforms symbols to strings in hash values. + def prepare_hash(hash) + return hash unless hash.is_a?(Hash) + hash = hash.dup + hash.each { |key, val| hash[key] = val.to_s if val.is_a?(Symbol) } + end + + # Thread-local hash to store async config for every client. + def thread_store + Thread.current[:telegram_bot_async] ||= {} + end + end + + attr_reader :id + + def initialize(*, id: nil, async: nil, **) + @id = id + self.async = async + super + end + + # Sets default async value for all threads. + # Uses `self.class.prepare_async_val` to prepare value. + def async=(val) + @async = self.class.prepare_async_val(val) + end + + # Sets async value in a thread-safe way for the block. + # Uses `self.class.prepare_async_val` to prepare value. + # + # If no block is given returns previously set value or the global one, + # set by #async=. + def async(val = true) + thread_key = object_id + thread_store = Async.thread_store + return thread_store.fetch(thread_key) { @async } unless block_given? + begin + old_val = thread_store.fetch(thread_key) { MISSING_VALUE } + thread_store[thread_key] = self.class.prepare_async_val(val) + yield + ensure + if MISSING_VALUE == old_val + thread_store.delete(thread_key) + else + thread_store[thread_key] = old_val + end + end + end + + # Uses job if #async is set. + def request(*args) + job_class = async + return super unless job_class + raise 'Can not enqueue job without client id' unless id + job_class.perform_later(id.to_s, *self.class.prepare_async_args(*args)) + end + end + end +end diff --git a/lib/telegram/bot/botan.rb b/lib/telegram/bot/botan.rb index 9c97a3e..75b3697 100644 --- a/lib/telegram/bot/botan.rb +++ b/lib/telegram/bot/botan.rb @@ -3,24 +3,37 @@ module Telegram class Botan TRACK_URI = 'https://api.botan.io/track'.freeze + autoload :ClientHelpers, 'telegram/bot/botan/client_helpers' + autoload :ControllerHelpers, 'telegram/bot/botan/controller_helpers' class Error < Bot::Error; end + extend Initializers + prepend Async include DebugClient + class << self + def by_id(id) + Telegram.botans[id] + end + + def prepare_async_args(method, uri, query = {}, body = nil) + [method.to_s, uri.to_s, Async.prepare_hash(query), body] + end + end + attr_reader :client, :token - def initialize(token) + def initialize(token = nil, **options) @client = HTTPClient.new - @token = token + @token = token || options[:token] end def track(event, uid, payload = {}) - res = http_request( - :post, - TRACK_URI, - {token: token, name: event, uid: uid}, - payload.to_json, - ) + request(:post, TRACK_URI, {name: event, uid: uid}, payload.to_json) + end + + def request(method, uri, query = {}, body = nil) + res = http_request(method, uri, query.merge(token: token), body) status = res.status return JSON.parse(res.body) if 300 > status result = JSON.parse(res.body) rescue nil # rubocop:disable RescueModifier @@ -31,6 +44,10 @@ module Telegram def http_request(method, uri, query, body) client.request(method, uri, query, body) end + + def inspect + "#<#{self.class.name}##{object_id}(#{@id})>" + end end end end diff --git a/lib/telegram/bot/botan/client_helpers.rb b/lib/telegram/bot/botan/client_helpers.rb new file mode 100644 index 0000000..b88efa1 --- /dev/null +++ b/lib/telegram/bot/botan/client_helpers.rb @@ -0,0 +1,15 @@ +module Telegram + module Bot + class Botan + # Helpers for botan.io metrics. + module ClientHelpers + attr_reader :botan + + def initialize(*, botan: nil, **) + super + @botan = Botan.wrap(botan, id: id) if botan + end + end + end + end +end diff --git a/lib/telegram/bot/updates_controller/botan.rb b/lib/telegram/bot/botan/controller_helpers.rb similarity index 94% rename from lib/telegram/bot/updates_controller/botan.rb rename to lib/telegram/bot/botan/controller_helpers.rb index b260600..02816c5 100644 --- a/lib/telegram/bot/updates_controller/botan.rb +++ b/lib/telegram/bot/botan/controller_helpers.rb @@ -1,8 +1,8 @@ module Telegram module Bot - class UpdatesController + class Botan # Helpers for botan.io metrics. - module Botan + module ControllerHelpers class MissingFrom < Error; end protected diff --git a/lib/telegram/bot/client.rb b/lib/telegram/bot/client.rb index 526c41a..a91d162 100644 --- a/lib/telegram/bot/client.rb +++ b/lib/telegram/bot/client.rb @@ -2,7 +2,6 @@ require 'json' require 'httpclient' require 'active_support/core_ext/string/inflections' require 'active_support/core_ext/hash/keys' -require 'telegram/bot/debug_client' module Telegram module Bot @@ -10,23 +9,14 @@ module Telegram URL_TEMPLATE = 'https://api.telegram.org/bot%s/'.freeze autoload :TypedResponse, 'telegram/bot/client/typed_response' + extend Initializers + prepend Async + prepend Botan::ClientHelpers include DebugClient 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'], botan: input['botan'] - when Symbol - Telegram.bots[input] or - raise "Bot #{input} not configured, check Telegram.bots_config." - else - new(input) - end + def by_id(id) + Telegram.bots[id] end # Prepend TypedResponse module. @@ -41,16 +31,19 @@ module Telegram body[k] = val.to_json if val.is_a?(Hash) || val.is_a?(Array) end end + + def prepare_async_args(action, body = {}) + [action.to_s, Async.prepare_hash(prepare_body(body))] + end end - attr_reader :client, :token, :username, :base_uri, :botan + attr_reader :client, :token, :username, :base_uri - def initialize(token, username = nil, botan: nil) + def initialize(token = nil, username = nil, **options) @client = HTTPClient.new - @token = token - @username = username - @base_uri = format URL_TEMPLATE, token - @botan = Botan.new(botan) if botan + @token = token || options[:token] + @username = username || options[:username] + @base_uri = format URL_TEMPLATE, self.token end def request(action, body = {}) # rubocop:disable PerceivedComplexity diff --git a/lib/telegram/bot/client_stub.rb b/lib/telegram/bot/client_stub.rb index 5a590b9..7269815 100644 --- a/lib/telegram/bot/client_stub.rb +++ b/lib/telegram/bot/client_stub.rb @@ -9,7 +9,7 @@ module Telegram if self == ClientStub || !ClientStub.stub_all? super else - ClientStub.new(args[1]) + ClientStub.new(*args) end end end @@ -34,8 +34,8 @@ module Telegram end end - def initialize(username = nil) - @username = username + def initialize(token = nil, username = nil, **options) + @username = username || options[:username] || token reset end diff --git a/lib/telegram/bot/config_methods.rb b/lib/telegram/bot/config_methods.rb index eb70be7..6272e21 100644 --- a/lib/telegram/bot/config_methods.rb +++ b/lib/telegram/bot/config_methods.rb @@ -26,7 +26,9 @@ module Telegram # Hash of bots made with bots_config. def bots - @bots ||= bots_config.transform_values(&Client.method(:wrap)) + @bots ||= bots_config.each_with_object({}) do |(id, config), h| + h[id] = Client.wrap(config, id: id) + end end # Default bot. @@ -34,17 +36,26 @@ module Telegram @bot ||= bots[:default] end + # Hash of botan clients made from #bots. + def botans + @botans ||= bots.transform_values(&:botan) + 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 + @bots_config ||= + if defined?(Rails) + telegram_config = Rails.application.secrets[:telegram] || {} + (telegram_config['bots'] || {}).symbolize_keys.tap do |config| + default = telegram_config['bot'] + config[:default] = default if default + end + else + {} + end end # Resets all cached bots and their configs. @@ -52,6 +63,7 @@ module Telegram @bots = nil @bot = nil @bots_config = nil + @botans = nil end end end diff --git a/lib/telegram/bot/initializers.rb b/lib/telegram/bot/initializers.rb new file mode 100644 index 0000000..b0f9c71 --- /dev/null +++ b/lib/telegram/bot/initializers.rb @@ -0,0 +1,19 @@ +module Telegram + module Bot + module Initializers + # Accepts different options to initialize bot. + def wrap(input, **options) + case input + when Symbol then by_id(input) or raise "#{name} #{input.inspect} not configured" + when self then input + when Hash then new(**input.symbolize_keys, **options) + else new(input, **options) + end + end + + def by_id(_id) + raise 'Not implemented' + end + end + end +end diff --git a/lib/telegram/bot/updates_controller.rb b/lib/telegram/bot/updates_controller.rb index a1fb482..1321e6a 100644 --- a/lib/telegram/bot/updates_controller.rb +++ b/lib/telegram/bot/updates_controller.rb @@ -59,7 +59,6 @@ module Telegram require 'telegram/bot/updates_controller/reply_helpers' autoload :CallbackQueryContext, 'telegram/bot/updates_controller/callback_query_context' autoload :MessageContext, 'telegram/bot/updates_controller/message_context' - autoload :Botan, 'telegram/bot/updates_controller/botan' include AbstractController::Callbacks # Redefine callbacks with default terminator. diff --git a/lib/telegram/bot/updates_poller.rb b/lib/telegram/bot/updates_poller.rb index 825dcdb..05e2b15 100644 --- a/lib/telegram/bot/updates_poller.rb +++ b/lib/telegram/bot/updates_poller.rb @@ -64,7 +64,7 @@ module Telegram end def fetch_updates - response = bot.get_updates(offset: offset, timeout: timeout) + response = bot.async(false) { bot.get_updates(offset: offset, timeout: timeout) } return unless response['ok'] && response['result'].any? reload! do response['result'].each do |update| diff --git a/lib/telegram/bot/version.rb b/lib/telegram/bot/version.rb index bf9e4e4..c9a72c1 100644 --- a/lib/telegram/bot/version.rb +++ b/lib/telegram/bot/version.rb @@ -1,6 +1,6 @@ module Telegram module Bot - VERSION = '0.8.0'.freeze + VERSION = '0.9.0.alpha2'.freeze def self.gem_version Gem::Version.new VERSION diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 459e2f2..cbf60f6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -17,6 +17,8 @@ require 'telegram/bot' require 'telegram/bot/updates_controller/rspec_helpers' require 'telegram/bot/types' +Dir[GEM_ROOT.join('spec/support/**/*.rb')].each { |f| require f } + RSpec.configure do |config| config.expect_with :rspec do |expectations| # This option will default to `true` in RSpec 4. It makes the `description` diff --git a/spec/support/examples/async.rb b/spec/support/examples/async.rb new file mode 100644 index 0000000..cbf384b --- /dev/null +++ b/spec/support/examples/async.rb @@ -0,0 +1,143 @@ +RSpec.shared_examples 'async' do |request_args: -> {}| + let(:instance) { described_class.new(token: token, id: id, async: async) } + let(:id) { :default_bot } + let(:async) { true } + let!(:application_job_class) do + klass = Class.new do + def self.perform_later(*) + end + end + klass.tap { |x| stub_const('ApplicationJob', x) } + end + after { described_class.default_async_job = nil } + + describe '#async' do + subject { ->(*args, &block) { instance.async(*args, &block) } } + its(:call) { should eq described_class.default_async_job } + + context 'when async is disabled' do + let(:async) { false } + its(:call) { should eq false } + end + + context 'when using with block' do + it 'sets value inside block' do + expect do + expect do + subject.call(false) do + expect do + subject.call(nil) { expect(subject[]).to eq nil } + end.to_not change(&subject).from false + raise 'TestError' + end + end.to raise_error(/TestError/) + end.to_not change(instance, :async).from(described_class.default_async_job) + end + end + + context 'in multi-threaded environment' do + it 'changes only in current thread' do + thread = nil + expect do + thread = Thread.new do + expect do + subject.call(false) do + expect(subject[]).to eq false + end + end.to_not change(instance, :async).from(described_class.default_async_job) + sleep 0.2 + end + end.to_not change(instance, :async).from(described_class.default_async_job) + thread.join + end + + it 'uses default value after block' do + thread = Thread.new do + subject.call(false) {} + expect { sleep 0.2 }.to change(instance, :async). + from(described_class.default_async_job).to(nil) + end + sleep 0.1 + instance.async = nil + thread.join + end + end + end + + describe '#async=' do + subject { ->(val = new_val) { instance.async = val } } + let(:async) {} + + context 'when true is given' do + let(:new_val) { true } + it { should change(instance, :async).to described_class.default_async_job } + end + + context 'when false is given' do + let(:new_val) { false } + it { should change(instance, :async).to false } + end + + context 'when string is given' do + let(:new_val) { 'Telegram::Bot::Client' } + it { should change(instance, :async).to Object.const_get(new_val) } + end + + context 'in multi-threaded environment' do + subject { ->(val = new_val) { Thread.new { instance.async = val }.join } } + let(:new_val) { true } + it { should change(instance, :async).to described_class.default_async_job } + end + end + + describe '.default_async_job' do + subject { described_class.default_async_job } + its(:superclass) { should eq application_job_class } + it { should include Telegram::Bot::Async::Job } + its(:client_class) { should eq described_class } + + context 'when ApplicationJob is not defined' do + let(:application_job_class) {} + it { expect { subject }.to raise_error(/Define ApplicationJob/) } + end + end + + describe '#request' do + subject { ->(*args) { instance.request(*(args.empty? ? self.args : args)) } } + let(:args, &request_args) + let(:result) { double(:result) } + + shared_examples 'enqueues job' do + it 'enqueues job' do + expect(instance).to_not receive(:http_request) + expect(described_class).to receive(:prepare_async_args).with(*args) { args } + expect(instance.async).to receive(:perform_later).with(id.to_s, *args) { result } + expect(subject.call).to eq result + end + end + + include_examples 'enqueues job' + + context 'with custom job class' do + let(:async) { double(:job_class) } + include_examples 'enqueues job' + end + + context 'when id is not set' do + let(:id) {} + it { should raise_error(/Can not enqueue/) } + end + + context 'when async is disabled' do + let(:async) { false } + let(:result) { double(status: 200, body: '{"test":"ok"}') } + let(:args, &request_args) + + it 'performs request immediately' do + expect(instance).to receive(:request).with(*args).and_call_original + expect(instance).to receive(:http_request) { result } + expect(subject[]).to eq 'test' => 'ok' + end + end + end +end diff --git a/spec/support/examples/initializers.rb b/spec/support/examples/initializers.rb new file mode 100644 index 0000000..552c985 --- /dev/null +++ b/spec/support/examples/initializers.rb @@ -0,0 +1,65 @@ +RSpec.shared_examples 'initializers' do |config_method = :bots| + describe '.wrap' do + subject { described_class.wrap(input, **options) } + let(:options) { {} } + let(:result) { double(:result) } + let(:username) { 'username' } + + 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 + + context 'and additional options are given' do + let(:options) { {id: :test} } + + it 'passes them to initializer' do + expect(described_class).to receive(:new).with(input, **options) { result } + should eq result + end + end + end + + context 'when input is a hash' do + let(:input) { {token: token, 'username' => username, other: :options} } + + it 'passes it with symbolized keys' do + expect(described_class).to receive(:new).with(**input.symbolize_keys) { result } + should eq result + end + + context 'and additional options are given' do + let(:options) { {id: :test} } + + it 'passes them to initializer' do + expect(described_class).to receive(:new). + with(**input.symbolize_keys, **options) { result } + should eq result + end + 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) { :client_1 } + before { allow(Telegram).to receive(config_method) { {client_1: instance} } } + it { should eq Telegram.send(config_method)[:client_1] } + + context 'and there is no such bot' do + let(:input) { :invalid } + it { expect { subject }.to raise_error(/not configured/) } + end + end + end +end diff --git a/spec/telegram/bot/async_spec.rb b/spec/telegram/bot/async_spec.rb new file mode 100644 index 0000000..ce13e43 --- /dev/null +++ b/spec/telegram/bot/async_spec.rb @@ -0,0 +1,28 @@ +RSpec.describe Telegram::Bot::Async::Job do + let(:job_class) do + described_class = self.described_class + client_class = self.client_class + Class.new do + include described_class + self.client_class = client_class + end + end + let(:client_class) { Telegram::Bot::Client } + let(:instance) { job_class.new } + + describe '#perform' do + subject { instance.perform(id, *args) } + let(:id) { 'bot_id' } + let(:args) { [double(:action), {body: :content}] } + let(:client) { Telegram::Bot::Client.new(async: custom_job_class) } + let(:custom_job_class) { Class.new } + let(:result) { double(status: 200, body: '{"test":"ok"}') } + + it 'finds client and performs request' do + expect(client_class).to receive(:wrap).with(id.to_sym) { client } + expect(client).to receive(:request).with(*args).and_call_original + expect(client).to receive(:http_request) { result } + should eq 'test' => 'ok' + end + end +end diff --git a/spec/telegram/bot/botan/client_helpers_spec.rb b/spec/telegram/bot/botan/client_helpers_spec.rb new file mode 100644 index 0000000..69945f4 --- /dev/null +++ b/spec/telegram/bot/botan/client_helpers_spec.rb @@ -0,0 +1,33 @@ +RSpec.describe Telegram::Bot::Client do + let(:instance) { described_class.new(*client_args) } + let(:token) { 'token' } + let(:botan_token) { double(:botan_token) } + let(:client_id) { 'client_id' } + let(:client_args) { [token] } + + describe '#botan' do + subject { instance.botan } + it { should eq nil } + + context 'when botan token is set' do + let(:client_args) { [token, id: client_id, async: Class.new, botan: botan_token] } + it { should be_instance_of Telegram::Bot::Botan } + its(:token) { should eq botan_token } + its(:id) { should eq client_id } + + it 'doesnt inherit async from client' do + expect(instance.async).to be + expect(subject.async).to_not be + end + end + + context 'when botan is configured with hash' do + let(:client_args) { [token, id: client_id, async: Class.new, botan: botan_config] } + let(:botan_config) { {token: botan_token, async: Class.new} } + it { should be_instance_of Telegram::Bot::Botan } + its(:token) { should eq botan_token } + its(:id) { should eq client_id } + its(:async) { should eq botan_config[:async] } + end + end +end diff --git a/spec/telegram/bot/updates_controller/botan_spec.rb b/spec/telegram/bot/botan/controller_helpers_spec.rb similarity index 96% rename from spec/telegram/bot/updates_controller/botan_spec.rb rename to spec/telegram/bot/botan/controller_helpers_spec.rb index 1b63717..43ac742 100644 --- a/spec/telegram/bot/updates_controller/botan_spec.rb +++ b/spec/telegram/bot/botan/controller_helpers_spec.rb @@ -1,4 +1,4 @@ -RSpec.describe Telegram::Bot::UpdatesController::Botan do +RSpec.describe Telegram::Bot::Botan::ControllerHelpers do include_context 'telegram/bot/updates_controller' let(:controller_class) do described_class = self.described_class diff --git a/spec/telegram/bot/botan_spec.rb b/spec/telegram/bot/botan_spec.rb new file mode 100644 index 0000000..03a072b --- /dev/null +++ b/spec/telegram/bot/botan_spec.rb @@ -0,0 +1,27 @@ +RSpec.describe Telegram::Bot::Botan do + let(:instance) { described_class.new 'token' } + let(:token) { 'token' } + + include_examples 'initializers', :botans + include_examples 'async', request_args: -> { [double(:method), double(:url)] } + + describe '.new' do + subject { described_class.new(*args) } + + context 'when usual args are given' do + let(:args) { ['secret'] } + its(:token) { should eq args[0] } + end + + context 'when options are given' do + let(:args) { [token: 'secret'] } + its(:token) { should eq args[0][:token] } + end + end + + describe '.prepare_async_args' do + subject { described_class.prepare_async_args(*input) } + let(:input) { [:post, :uri, {a: 1, b: :sym, 'd' => 'str'}, 'body'] } + it { should eq ['post', 'uri', {a: 1, b: 'sym', 'd' => 'str'}, 'body'] } + end +end diff --git a/spec/telegram/bot/client_spec.rb b/spec/telegram/bot/client_spec.rb index 786531a..df4d1a8 100644 --- a/spec/telegram/bot/client_spec.rb +++ b/spec/telegram/bot/client_spec.rb @@ -3,71 +3,8 @@ RSpec.describe Telegram::Bot::Client do let(:token) { 'token' } let(:botan_token) { double(:botan_token) } - describe '.wrap' do - subject { described_class.wrap(input) } - let(:result) { double(:result) } - let(:username) { 'username' } - - 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, botan: nil) { result } - should eq result - end - - context 'when `botan` is given' do - let(:input) { super().merge(botan: botan_token) } - - it 'passes it to initializer' do - expect(described_class).to receive(:new). - with(token, username, botan: botan_token) { result } - should eq result - end - 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, botan: nil) { result_2 } - should eq [result, instance, result_2] - end - end - end + include_examples 'initializers' + include_examples 'async', request_args: -> { [double(:action), {body: :content}] } describe '.prepare_body' do subject { described_class.prepare_body(input) } @@ -88,14 +25,27 @@ RSpec.describe Telegram::Bot::Client do end end - describe '#botan' do - subject { instance.botan } - it { should eq nil } + describe '.prepare_async_args' do + subject { described_class.prepare_async_args(*input) } + let(:input) { [:action, a: 1, b: :sym, c: [:other], 'd' => 'str'] } + it { should eq ['action', a: 1, b: 'sym', c: '["other"]', 'd' => 'str'] } + end - context 'when botan token is set' do - let(:instance) { described_class.new token, botan: botan_token } - it { should be_instance_of Telegram::Bot::Botan } - its(:token) { should eq botan_token } + describe '.new' do + subject { described_class.new(*args) } + + context 'when multiple args are given' do + let(:args) { %w(secret superbot) } + its(:token) { should eq args[0] } + its(:username) { should eq args[1] } + its(:base_uri) { should include args[0] } + end + + context 'when hash is given' do + let(:args) { [token: 'secret', username: 'superbot'] } + its(:token) { should eq args[0][:token] } + its(:username) { should eq args[0][:username] } + its(:base_uri) { should include args[0][:token] } end end end diff --git a/spec/telegram/bot/client_stub_spec.rb b/spec/telegram/bot/client_stub_spec.rb index 64af69f..59390e2 100644 --- a/spec/telegram/bot/client_stub_spec.rb +++ b/spec/telegram/bot/client_stub_spec.rb @@ -1,7 +1,7 @@ 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']) } + let(:clients) { ['token', token: 'token2'].map(&Telegram::Bot::Client.method(:wrap)) } shared_examples 'constructors' do |expected_class| it 'makes Client.new return ClientStub' do @@ -9,7 +9,7 @@ RSpec.describe Telegram::Bot::ClientStub do expect(client.username).to eq 'bot_name' end - it 'makes Client.wrap raturn ClientStub' do + it 'makes Client.wrap return ClientStub' do expect(clients).to contain_exactly instance_of(expected_class), instance_of(expected_class) end @@ -35,4 +35,19 @@ RSpec.describe Telegram::Bot::ClientStub do include_examples 'constructors', Telegram::Bot::Client end end + + describe '#new' do + subject { described_class.new(*args) } + + context 'when only username is given' do + let(:args) { 'superbot' } + its(:username) { should eq args } + end + + context 'when username and token are given' do + let(:args) { %w(token superbot) } + its(:token) { should eq nil } + its(:username) { should eq args[1] } + end + end end diff --git a/spec/telegram/bot/config_methods_spec.rb b/spec/telegram/bot/config_methods_spec.rb new file mode 100644 index 0000000..f806896 --- /dev/null +++ b/spec/telegram/bot/config_methods_spec.rb @@ -0,0 +1,94 @@ +RSpec.describe Telegram::Bot::ConfigMethods do + let(:registry) do + Object.new.tap do |x| + x.extend described_class + x.bots_config = config + end + end + let(:config) do + { + default: 'default_token', + chat: { + token: 'chat_token', + username: 'Chat', + botan: 'chat_botan_token', + }, + other_chat: { + 'token' => 'other_chat_token', + 'username' => 'OtherChat', + 'botan' => 'other_chat_botan_token', + }, + } + end + + describe '#bot' do + subject { registry.bot } + it { should eq registry.bots[:default] } + end + + describe '#bots' do + context 'configured by token' do + subject { registry.bots[:default] } + its(:id) { should eq :default } + its(:token) { should eq config[:default] } + end + + context 'configured by hash' do + subject { registry.bots[:chat] } + its(:id) { should eq :chat } + its(:token) { should eq config[:chat][:token] } + its(:username) { should eq config[:chat][:username] } + its('botan.token') { should eq config[:chat][:botan] } + end + + context 'configured by hash with stringified keys' do + subject { registry.bots[:other_chat] } + its(:id) { should eq :other_chat } + its(:token) { should eq config[:other_chat]['token'] } + its(:username) { should eq config[:other_chat]['username'] } + its('botan.token') { should eq config[:other_chat]['botan'] } + end + end + + describe '#botans' do + subject { registry.botans } + it do + should eq( + default: nil, + chat: registry.bots[:chat].botan, + other_chat: registry.bots[:other_chat].botan, + ) + end + end + + describe '#bots_config' do + subject { registry.bots_config } + it { should eq config } + + context 'when not configured' do + let(:registry) { Object.new.tap { |x| x.extend described_class } } + it { should eq({}) } + + context 'in rails environment' do + before { stub_const('Rails', double(application: double(secrets: secrets))) } + let(:secrets) { {} } + it { should eq({}) } + + context 'when there is telegram section in secrets' do + let(:secrets) { {telegram: config.stringify_keys} } + let(:config) do + { + bot: double(:bot_config), + bots: { + chat: double(:chat_config), + other_chat: double(:other_chat_config), + }, + } + end + it { should include default: config[:bot] } + it { should include config[:bots] } + end + end + end + end +end