commit 37a63f7b2307efdad2d65b34b24fbd8945e7f7bd Author: Max Melentiev Date: Mon Feb 22 22:53:59 2016 +0600 v0.1.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cb6eeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..83e16f8 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..8c48293 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,27 @@ +Rails: {Enabled: true} + +Style/Alias: {Enabled: false} +Style/AlignParameters: + # Disable, till rubocop supports combination of styles. + # Use one of this styles where appropriate, keep it clean, compact and readable. + Enabled: false + # EnforcedStyle: + # - with_first_parameter + # - with_fixed_indentation +Style/ClosingParenthesisIndentation: {Enabled: false} +Style/Documentation: {Enabled: false} +Style/DotPosition: {EnforcedStyle: trailing} +Style/IfUnlessModifier: {Enabled: false} +Style/ModuleFunction: {Enabled: false} +Style/MultilineOperationIndentation: {EnforcedStyle: indented} +Style/NestedParenthesizedCalls: {Enabled: false} +Style/PredicateName: {Enabled: false} +Style/SignalException: {EnforcedStyle: only_raise} +Style/SpaceInsideHashLiteralBraces: {EnforcedStyle: no_space} +Style/TrailingCommaInArguments: {Enabled: false} +Style/TrailingCommaInLiteral: {EnforcedStyleForMultiline: comma} + +Metrics/AbcSize: {Max: 21} +Metrics/LineLength: {Max: 100} +Metrics/MethodLength: {Max: 30} +Metrics/CyclomaticComplexity: {Max: 8} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0d16897 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: ruby +cache: bundler +rvm: + - 2.2.3 +notifications: + email: false diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..f9cc34d --- /dev/null +++ b/Gemfile @@ -0,0 +1,15 @@ +source 'https://rubygems.org' +gemspec + +group :development do + gem 'sdoc', '~> 0.4.1' + gem 'pry', '~> 0.10.1' + gem 'pry-byebug', '~> 3.2.0' + + gem 'rspec', '~> 3.3.0' + gem 'rspec-its', '~> 1.1.0' + + gem 'rubocop', '~> 0.37.0' + + gem 'coveralls', '~> 0.8.2', require: false +end diff --git a/README.md b/README.md new file mode 100644 index 0000000..feee697 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# 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) +[![Build Status](https://travis-ci.org/printercu/telegram-bot.svg)](https://travis-ci.org/printercu/telegram-bot) + +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 +in development, but you can use poller in production if you want. + +Package contains: + +- Ligthweight client to bot API (with fast and thread-safe + [httpclient](https://github.com/nahi/httpclient) is under the hood.) +- Controller with message parser. Allows to write separate methods for each command. +- Middleware and routes helpers for production env. +- Poller with automatic source-reloader for development env. +- Rake tasks to update webhook urls. + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'telegram-bot' +``` + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install telegram-bot + +## Usage + +### Configuration + +Add `telegram` section into `secrets.yml`: + +```yml +telegram: + bots: + # just set the token + chat: TOKEN_1 + # or add username to support commands with mentions (/help@ChatBot) + auction: + token: TOKEN_2 + username: ChatBot + + # Single bot can be specified like this + bot: TOKEN + # or + bot: + token: TOKEN + username: SomeBot +``` + +### Client + +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]`. + +You can create clients manually with `Telegram::Bot.new(token, username)`. +Username is optional and used only to parse commands with mentions. + +Client has all available methods in underscored style +(`answer_inline_query` instead of `answerInlineQuery`). +All this methods just post given params to specific URL. + +```ruby +bot.send_message chat_id: chat_id, text: 'Test' +``` + +### Controller + +```ruby +class Telegram::WebhookController < Telegram::Bot::UpdatesController + # use callbacks like in any other controllers + around_action :set_locale + + # Every update can have one of: message, inline_query & chosen_inline_result. + # Define method with same name to respond to this updates. + def message(message) + # message can be also accessed via instance method + message == self.payload # true + # store_message(message['text']) + end + + # Define public methods to respond to commands. + # Command arguments will be parsed and passed to the method. + # Be sure to use splat args and default values to not get errors when + # someone passed more or less arguments in the message. + # + # For some commands like /message or /123 method names should start with + # `on_` to avoid conflicts. + def start(data = nil, *) + # do_smth_with(data) + + # There are `chat` & `from` shortcut methods. + response = from ? "Hello #{from['username']}!" : 'Hi there!' + # There is `reply_with` helper to set basic fields + # like `reply_to_message` & `chat_id`. + reply_with :message, text: response + end + + private + + def set_locale(&block) + I18n.with_locale(locale_for_update, &block) + end + + def locale_for_update + if from + # locale for user + elsif chat + # locale for chat + end + end +end +``` + +### Routes + +Use `telegram_webhooks` helper to add routes. It will create routes for bots +at "telegram/#{bot.token}" path. + +```ruby +# Create routes for all Telegram.bots to use same controller: +telegram_webhooks TelegramController + +# Or pass custom bots usin any of supported config options: +telegram_webhooks TelegramController, + bot, + {token: token, username: username}, + other_bot_token + +# Use different controllers for each bot: +telegram_webhooks bot => TelegramChatController, + other_bot => TelegramAuctionController + +# telegram_webhooks creates named routes. +# Route name depends on `Telegram.bots`. +# When there is single bot it will use 'telegram_webhook'. +# When there are it will use bot's key in the `Telegram.bots` as prefix +# (eg. `chat_telegram_webhook`). +# You can override this options or specify others: +telegram_webhooks TelegramController, as: :my_webhook +telegram_webhooks bot => [TelegramChatController, as: :chat_webhook], + other_bot => [TelegramAuctionController, +``` + +For Rack applications you can also use `Telegram::Bot::Middleware` or just +call `.dispatch(bot, update)` on controller. + +### Development & Debugging + +Use `rake telegram:bot:poller BOT=chat` to run poller. It'll automatically load +changes without restart in development env. This task will not if you don't use +`telegram_webhooks`. + +You can run poller manually with +`Telegram::Bot::UpdatesPoller.start(bot, controller_class)`. + +### Deploying + +Use `rake telegram:bot:set_webhook` to update webhook url for all configured bots. +Certificate can be specified with `CERT=path/to/cert`. + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. +Then, run `rake spec` to run the tests. +You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. +To release a new version, update the version number in `version.rb`, +and then run `bundle exec rake release`, which will create a git tag for the version, +push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/printercu/telegram-bot. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..a000ffb --- /dev/null +++ b/Rakefile @@ -0,0 +1,22 @@ +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec + +require 'sdoc' +RDoc::Task.new(:doc) do |rdoc| + rdoc.rdoc_dir = 'doc' + + rdoc.title = 'RailsStuff' + + rdoc.options << '--markup' << 'markdown' + rdoc.options << '-e' << 'UTF-8' + rdoc.options << '--format' << 'sdoc' + rdoc.options << '--template' << 'rails' + rdoc.options << '--all' + + rdoc.rdoc_files.include('README.md') + rdoc.rdoc_files.include('lib/**/*.rb') +end diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..604f587 --- /dev/null +++ b/bin/console @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +require 'telegram/bot' + +require 'pry' +Pry.start diff --git a/bin/git-hooks/pre-commit b/bin/git-hooks/pre-commit new file mode 100755 index 0000000..f32199d --- /dev/null +++ b/bin/git-hooks/pre-commit @@ -0,0 +1,14 @@ +#!/bin/bash + +pattern=$(echo -n '\.rb +\.gemspec +\.jbuilder +\.rake +config\.ru +Gemfile +Rakefile' | tr "\\n" '|') + +files=`git diff --cached --name-status | grep -E "^[AM].*($pattern)$" | cut -f2-` +if [ -n "$files" ]; then + bundle exec rubocop $files --force-exclusion +fi diff --git a/bin/install_git_hooks b/bin/install_git_hooks new file mode 100755 index 0000000..4b48122 --- /dev/null +++ b/bin/install_git_hooks @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby + +root = File.expand_path('../../', __FILE__) +hooks_dir = "#{root}/bin/git-hooks" + +`ls -1 #{hooks_dir}`.each_line.map(&:strip).each do |file| + `ln -sf #{hooks_dir}/#{file} #{root}/.git/hooks/#{file}` +end diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..fe07d3b --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail +IFS=$'\n\t' + +bundle install +bin/install_git_hooks + +# Do any other automated setup that you need to do here diff --git a/lib/tasks/telegram-bot.rake b/lib/tasks/telegram-bot.rake new file mode 100644 index 0000000..e8cf4d4 --- /dev/null +++ b/lib/tasks/telegram-bot.rake @@ -0,0 +1,22 @@ +namespace :telegram do + namespace :bot do + desc 'Run poller' + task poller: :environment do + console = ActiveSupport::Logger.new(STDERR) + Rails.logger.extend ActiveSupport::Logger.broadcast console + Telegram::Bot::UpdatesPoller.start(ENV['BOT'].try!(:to_sym) || :default) + end + + desc 'Set webhook urls for all bots' + task set_webhook: :environment do + routes = Rails.application.routes.url_helpers + cert_file = ENV['CERT'] + cert = File.open(cert_file) if cert_file + Telegram.bots.each_value do |bot| + route_name = Telegram::RoutesHelper.route_name_for_bot(bot) + url = routes.send("#{route_name}_url") + bot.set_webhook(url: url, certificate: cert) + end + end + end +end diff --git a/lib/telegram/bot.rb b/lib/telegram/bot.rb new file mode 100644 index 0000000..71b69e0 --- /dev/null +++ b/lib/telegram/bot.rb @@ -0,0 +1,98 @@ +require 'httpclient' +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 + extend Bottable + + class Bot + class Error < StandardError; end + class NotFound < Error; end + + autoload :Middleware, 'telegram/bot/middleware' + autoload :UpdatesController, 'telegram/bot/updates_controller' + 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 + "#" + end + end +end + +require 'telegram/bot/railtie' if defined?(Rails) diff --git a/lib/telegram/bot/middleware.rb b/lib/telegram/bot/middleware.rb new file mode 100644 index 0000000..4b38f4e --- /dev/null +++ b/lib/telegram/bot/middleware.rb @@ -0,0 +1,26 @@ +require 'active_support/concern' +require 'action_dispatch/http/mime_type' +require 'action_dispatch/middleware/params_parser' + +module Telegram + class Bot + class Middleware + attr_reader :bot, :controller + + def initialize(bot, controller) + @bot = bot + @controller = controller + end + + def call(env) + update = env['action_dispatch.request.request_parameters'] + controller.dispatch(bot, update) + [200, {}, ''] + end + + def inspect + "#<#{self.class.name}(#{controller.try!(:name)})>" + end + end + end +end diff --git a/lib/telegram/bot/railtie.rb b/lib/telegram/bot/railtie.rb new file mode 100644 index 0000000..09315b0 --- /dev/null +++ b/lib/telegram/bot/railtie.rb @@ -0,0 +1,33 @@ +require 'telegram/bot/routes_helper' + +module Telegram + class Bot + class Railtie < Rails::Railtie + config.telegram_updates_controller = ActiveSupport::OrderedOptions.new + + rake_tasks do + load 'tasks/telegram-bot.rake' + end + + config.before_initialize do + ::ActionDispatch::Routing::Mapper.send(:include, RoutesHelper) + end + + initializer 'telegram.bot.updates_controller.set_config' do |app| + options = app.config.telegram_updates_controller + + ActiveSupport.on_load('telegram.bot.updates_controller') do + self.logger = options.logger || Rails.logger + end + end + + initializer 'telegram.bot.updates_controller.add_ar_runtime' do + ActiveSupport.on_load('telegram.bot.updates_controller') do + if defined?(ActiveRecord::Railties::ControllerRuntime) + include ActiveRecord::Railties::ControllerRuntime + end + end + end + end + end +end diff --git a/lib/telegram/bot/routes_helper.rb b/lib/telegram/bot/routes_helper.rb new file mode 100644 index 0000000..01bdfd0 --- /dev/null +++ b/lib/telegram/bot/routes_helper.rb @@ -0,0 +1,58 @@ +require 'telegram/bot' + +module Telegram + class Bot + module RoutesHelper + class << self + # Returns route name for given bot. Result depends on `Telegram.bots`. + # When there is single bot it returns 'telegram_webhook'. + # When there are it will use bot's key in the `Telegram.bots` as prefix + # (eg. `chat_telegram_webhook`). + def route_name_for_bot(bot) + bots = Telegram.bots + if bots.size != 1 + name = bots.invert[bot] + name && "#{name}_telegram_webhook" + end || 'telegram_webhook' + end + end + + # # Create routes for all Telegram.bots to use same controller: + # telegram_webhooks TelegramController + # + # # Or pass custom bots usin any of supported config options: + # telegram_webhooks TelegramController, + # bot, + # {token: token, username: username}, + # other_bot_token + # + # # Use different controllers for each bot: + # telegram_webhooks bot => TelegramChatController, + # other_bot => TelegramAuctionController + # + # # telegram_webhooks creates named routes. See + # # RoutesHelper.route_name_for_bot for more info. + # # You can override this options or specify others: + # telegram_webhooks TelegramController, as: :my_webhook + # telegram_webhooks bot => [TelegramChatController, as: :chat_webhook], + # other_bot => [TelegramAuctionController, + def telegram_webhooks(controllers, bots = nil, **options) + unless controllers.is_a?(Hash) + bots = bots ? Array.wrap(bots) : Telegram.bots.values + controllers = Hash[bots.map { |x| [x, controllers] }] + end + controllers.each do |bot, controller| + bot = Bot.wrap(bot) + controller, bot_options = controller if controller.is_a?(Array) + params = { + to: Middleware.new(bot, controller), + as: RoutesHelper.route_name_for_bot(bot), + format: false, + }.merge!(options).merge!(bot_options || {}) + post("telegram/#{bot.token}", params) + UpdatesPoller.add(bot, controller) if Telegram.bot_poller_mode? + end + end + end + end +end diff --git a/lib/telegram/bot/updates_controller.rb b/lib/telegram/bot/updates_controller.rb new file mode 100644 index 0000000..f910321 --- /dev/null +++ b/lib/telegram/bot/updates_controller.rb @@ -0,0 +1,105 @@ +require 'abstract_controller' +require 'active_support/callbacks' + +module Telegram + class Bot + class UpdatesController < AbstractController::Base + include AbstractController::Callbacks + include AbstractController::Translation + + require 'telegram/bot/updates_controller/log_subscriber' + require 'telegram/bot/updates_controller/instrumentation' + prepend Instrumentation + + PAYLOAD_TYPES = %w( + message + inline_query + chosen_inline_result + ).freeze + CMD_REGEX = %r{\A/([a-z\d_]{,31})(@(\S+))?(\s|$)}i + CONFLICT_CMD_REGEX = Regexp.new("^(#{PAYLOAD_TYPES.join('|')}|\\d)") + abstract! + + class << self + def dispatch(*args) + new(*args).dispatch + end + + # Overrid it to filter or transform commands. + # Default implementation is to convert to downcase and add `on_` prefix + # for conflicting commands. + def action_for_command(cmd) + cmd.downcase! + cmd.match(CONFLICT_CMD_REGEX) ? "on_#{cmd}" : cmd + end + + # Fetches command from text message. All subsequent words are returned + # as arguments. + # If command has mention (eg. `/test@SomeBot`), it returns commands only + # for specified username. Set `username` to `true` to accept + # any commands. + def command_from_text(text, username = nil) + return unless text + match = text.match CMD_REGEX + return unless match + return if match[3] && username != true && match[3] != username + [match[1], text.split(' ').drop(1)] + end + end + + attr_internal_reader :update, :bot, :payload, :payload_type, :is_command + alias_method :command?, :is_command + delegate :username, to: :bot, prefix: true, allow_nil: true + + def initialize(bot = nil, update = nil) + @_update = update + @_bot = bot + + update && PAYLOAD_TYPES.find do |type| + item = update[type] + next unless item + @_payload = item + @_payload_type = type + end + end + + def dispatch + @_is_command, action, args = action_for_payload + process(action, *args) + end + + # Calculates action name and args for payload. + # If payload is a message with command, then returned action is an + # action for this command. Otherwise it's the same as payload type. + # Returns array `[is_command?, action, args]`. + def action_for_payload + case payload_type + when 'message' + cmd, args = self.class.command_from_text(payload['text'], bot_username) + cmd &&= self.class.action_for_command(cmd) + [true, cmd, args] if cmd + end || [false, payload_type, [payload]] + end + + # Silently ignore unsupported messages. + # Params are `action, *args`. + def action_missing(*) + end + + %w(chat from).each do |field| + define_method(field) { payload[field] } + end + + def reply_with(type, params) + method = "send_#{type}" + params = params.merge( + chat_id: chat['id'], + reply_to_message: payload['message_id'], + ) + bot.public_send(method, params) + end + + ActiveSupport.run_load_hooks('telegram.bot.updates_controller', self) + end + end +end diff --git a/lib/telegram/bot/updates_controller/instrumentation.rb b/lib/telegram/bot/updates_controller/instrumentation.rb new file mode 100644 index 0000000..a0f40c6 --- /dev/null +++ b/lib/telegram/bot/updates_controller/instrumentation.rb @@ -0,0 +1,79 @@ +module Telegram + class Bot + class UpdatesController + # Most methods are taken from ActionController::Instrumentation, + # some are slightly modified. + module Instrumentation + class << self + def prepended(base) + base.config_accessor :logger + base.extend ClassMethods + end + + def instrument(action, *args, &block) + ActiveSupport::Notifications.instrument( + "#{action}.updates_controller.bot.telegram", + *args, + &block + ) + end + end + + def process_action(*args) + raw_payload = { + controller: self.class.name, + action: action_name, + update: update, + } + Instrumentation.instrument(:start_processing, raw_payload.dup) + Instrumentation.instrument(:process_action, raw_payload) do |payload| + begin + super + ensure + append_info_to_payload(payload) + end + end + end + + def reply_with(type, *) + Instrumentation.instrument(:reply_with, type: type) { super } + end + + private + + # A hook invoked every time a before callback is halted. + def halted_callback_hook(filter) + Instrumentation.instrument(:halted_callback, filter: filter) + end + + # A hook which allows you to clean up any time taken into account in + # views wrongly, like database querying time. + # + # def cleanup_view_runtime + # super - time_taken_in_something_expensive + # end + # + # :api: plugin + def cleanup_view_runtime #:nodoc: + yield + end + + # Every time after an action is processed, this method is invoked + # with the payload, so you can add more information. + # :api: plugin + def append_info_to_payload(_payload) #:nodoc: + end + + module ClassMethods + # A hook which allows other frameworks to log what happened during + # controller process action. This method should return an array + # with the messages to be added. + # :api: plugin + def log_process_action(_payload) #:nodoc: + [] + end + end + end + end + end +end diff --git a/lib/telegram/bot/updates_controller/log_subscriber.rb b/lib/telegram/bot/updates_controller/log_subscriber.rb new file mode 100644 index 0000000..9ed9d0a --- /dev/null +++ b/lib/telegram/bot/updates_controller/log_subscriber.rb @@ -0,0 +1,38 @@ +require 'active_support/log_subscriber' + +module Telegram + class Bot + class UpdatesController + class LogSubscriber < ActiveSupport::LogSubscriber + def start_processing(event) + info do + payload = event.payload + "Processing by #{payload[:controller]}##{payload[:action]}\n" \ + " Update: #{payload[:update].to_json}" + end + end + + def process_action(event) + info do + payload = event.payload + additions = UpdatesController.log_process_action(payload) + message = "Completed in #{event.duration.round}ms" + message << " (#{additions.join(' | ')})" unless additions.blank? + message + end + end + + def reply_with(event) + info { "Replied with #{event.payload[:type]}" } + end + + def halted_callback(event) + info { "Filter chain halted at #{event.payload[:filter].inspect}" } + end + + delegate :logger, to: UpdatesController + attach_to 'updates_controller.bot.telegram' + end + end + end +end diff --git a/lib/telegram/bot/updates_poller.rb b/lib/telegram/bot/updates_poller.rb new file mode 100644 index 0000000..2fa1c57 --- /dev/null +++ b/lib/telegram/bot/updates_poller.rb @@ -0,0 +1,88 @@ +module Telegram + class Bot + # Supposed to be used in development environments only. + class UpdatesPoller + class << self + @@instances = {} # rubocop:disable ClassVars + + def instances + @@instances + end + + # Create, start and add poller instnace to tracked instances list. + def add(bot, controller) + new(bot, controller).tap { |x| instances[bot] = x } + end + + def start(bot_id, controller = nil) + bot = bot_id.is_a?(Symbol) ? Telegram.bots[bot_id] : Bot.wrap(bot_id) + instance = controller ? new(bot, controller) : instances[bot] + raise "Poller not found for #{bot_id.inspect}" unless instance + instance.start + end + end + + DEFAULT_TIMEOUT = 5 + + attr_reader :bot, :controller, :timeout, :offset, :logger, :running, :reload + + def initialize(bot, controller, **options) + @logger = options.fetch(:logger) { defined?(Rails) && Rails.logger } + @bot = bot + @controller = controller + @timeout = options.fetch(:timeout) { DEFAULT_TIMEOUT } + @offset = options[:offset] + @reload = options.fetch(:reload) { defined?(Rails) && Rails.env.development? } + end + + def log(&block) + logger.info(&block) if logger + end + + def start + return if running + @running = true + log { 'Started bot poller.' } + while running + begin + fetch_updates do |update| + controller.dispatch(bot, update) + end + rescue Interrupt + @running = false + rescue => e + logger.error { ([e.message] + e.backtrace).join("\n") } if logger + end + end + log { 'Stop polling bot updates.' } + end + + def stop + return unless running + log { 'Killing polling thread.' } + @running = false + end + + def fetch_updates + response = bot.get_updates(offset: offset, timeout: timeout) + return unless response['ok'] && response['result'].any? + reload! do + response['result'].each do |update| + @offset = update['update_id'] + 1 + yield update + end + end + rescue Timeout::Error # rubocop:disable HandleExceptions + end + + def reload! + return yield unless reload + ActionDispatch::Reloader.prepare! + if controller.is_a?(Class) && controller.name + @controller = Object.const_get(controller.name) + end + yield.tap { ActionDispatch::Reloader.cleanup! } + end + end + end +end diff --git a/lib/telegram/bot/version.rb b/lib/telegram/bot/version.rb new file mode 100644 index 0000000..22476ed --- /dev/null +++ b/lib/telegram/bot/version.rb @@ -0,0 +1,9 @@ +module Telegram + class Bot + VERSION = '0.1.0'.freeze + + def self.gem_version + Gem::Version.new VERSION + end + end +end diff --git a/lib/telegram/bottable.rb b/lib/telegram/bottable.rb new file mode 100644 index 0000000..9f1bb1e --- /dev/null +++ b/lib/telegram/bottable.rb @@ -0,0 +1,41 @@ +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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..75c496c --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,81 @@ +require 'pathname' +require 'pry' +require 'rspec/its' + +if ENV['CI'] + require 'coveralls' + Coveralls.wear! +elsif ENV.key?('COV') + require 'simplecov' + SimpleCov.start +end + +GEM_ROOT = Pathname.new File.expand_path('../..', __FILE__) + +$LOAD_PATH.unshift GEM_ROOT.join('lib') +require 'telegram/bot' + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # These two settings work together to allow you to limit a spec run + # to individual examples or groups you care about by tagging them with + # `:focus` metadata. When nothing is tagged with `:focus`, all examples + # get run. + # + # Use `FULL=true bin/rspec` to disable filters. + config.filter_run :focus + config.run_all_when_everything_filtered = true + + # Limits the available syntax to the non-monkey patched syntax that is recommended. + # For more details, see: + # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax + # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + config.default_formatter = 'doc' if config.files_to_run.one? + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + # config.profile_examples = 3 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed + + # Make it more convenient. + require 'bigdecimal' + BigDecimal.class_eval do + alias_method :inspect_orig, :inspect + alias_method :inspect, :to_s + end +end diff --git a/spec/telegram/bot/middleware_spec.rb b/spec/telegram/bot/middleware_spec.rb new file mode 100644 index 0000000..a4c62c5 --- /dev/null +++ b/spec/telegram/bot/middleware_spec.rb @@ -0,0 +1,18 @@ +RSpec.describe Telegram::Bot::Middleware do + let(:instance) { described_class.new bot, controller } + let(:bot) { double(:bot) } + let(:controller) { double(:controller, dispatch: :dispatch_result) } + + describe '#call' do + subject { instance.call(env) } + let(:env) { {'action_dispatch.request.request_parameters' => json_body} } + let(:json_body) { double(:json_body) } + + it 'calls dispatch on controller' do + expect(controller).to receive(:dispatch).with(bot, json_body) + subject + end + + it { should eq [200, {}, ''] } + end +end diff --git a/spec/telegram/bot/routes_helper_spec.rb b/spec/telegram/bot/routes_helper_spec.rb new file mode 100644 index 0000000..01e4ed2 --- /dev/null +++ b/spec/telegram/bot/routes_helper_spec.rb @@ -0,0 +1,117 @@ +require 'telegram/bot/routes_helper' + +RSpec.describe Telegram::Bot::RoutesHelper do + let(:bot) { Telegram::Bot.new('bot_token') } + let(:other_bot) { Telegram::Bot.new('other_token') } + let(:bots) { {default: bot, other: other_bot} } + + describe '.route_name_for_bot' do + subject { described_class.route_name_for_bot(input) } + before { expect(Telegram).to receive(:bots) { bots } } + + context 'when there is only one bot' do + let(:bots) { {default: bot} } + + context 'for existing bot' do + let(:input) { bot } + it { should eq 'telegram_webhook' } + end + + context 'for non-existing bot' do + let(:input) { other_bot } + it { should eq 'telegram_webhook' } + end + end + + context 'when there are multiple bots' do + context 'for existing bot' do + let(:input) { bot } + it { should eq 'default_telegram_webhook' } + end + + context 'for non-existing bot' do + let(:input) { double(:missing_bot) } + it { should eq 'telegram_webhook' } + end + end + end + + describe '#telegram_webhooks' do + subject { mapper.telegram_webhooks(*input) } + let(:mapper) { double(:mapper).tap { |x| x.extend described_class } } + let(:bots) { {default: bot, other: other_bot} } + let(:controller) { double(:controller, name: :controller) } + let(:other_controller) { double(:other_controller, name: :other_controller) } + before { allow(Telegram).to receive(:bots) { bots } } + + def assert_routes(*expected) # rubocop:disable AbcSize + expected.each do |(bot, controller, route_name, options)| + expect(mapper).to receive(:post) do |path, params| + expect(path).to eq "telegram/#{bot.token}" + middleware = params[:to] + expect(middleware.controller).to eq(controller) + expect(middleware.bot.token).to eq(bot.token) + expect(middleware.bot.username).to eq(bot.username) + expect(params[:as]).to eq route_name + expect(params).to include(options) if options + end + end + subject + end + + context 'when called with controller' do + let(:input) { [controller, option: :val] } + + it 'creates routes for every bot and this controller' do + assert_routes [bot, controller, 'default_telegram_webhook', option: :val], + [other_bot, controller, 'other_telegram_webhook', option: :val] + end + end + + context 'when called with hash' do + let(:input) do + [ + { + bot => controller, + 'custom_token' => [other_controller, as: :custom_route, option: :other_val], + }, + option: :val, + ] + end + + it 'creates routes for every bot and its controller' do + assert_routes [bot, controller, 'default_telegram_webhook', option: :val], + [ + Telegram::Bot.new('custom_token'), + other_controller, + :custom_route, + option: :other_val, + ] + end + end + + context 'when called with controller and smth castable to bot' do + let(:input) do + [ + controller, + ['custom_token', token: bot.token, username: 'new_name'], + option: :val, + ] + end + + it 'creates routes for every created bot and controller' do + assert_routes [ + Telegram::Bot.new('custom_token'), + controller, + 'telegram_webhook', + option: :val, + ], [ + Telegram::Bot.new(bot.token, 'new_name'), + controller, + 'telegram_webhook', + option: :val, + ] + end + end + end +end diff --git a/spec/telegram/bot/updates_controller_spec.rb b/spec/telegram/bot/updates_controller_spec.rb new file mode 100644 index 0000000..c7910ed --- /dev/null +++ b/spec/telegram/bot/updates_controller_spec.rb @@ -0,0 +1,133 @@ +RSpec.describe Telegram::Bot::UpdatesController do + let(:instance) { described_class.new(bot, update) } + 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' } + + describe '.action_for_command' do + subject { ->(*args) { described_class.action_for_command(*args) } } + + def assert_subject(input, expected) + expect(subject.call input).to eq expected + end + + it 'bypasses and downcases not conflictint commands' do + assert_subject 'test', 'test' + assert_subject 'TeSt', 'test' + assert_subject '_Te1St', '_te1st' + end + + it 'adds _on to conflicting commands' do + described_class::PAYLOAD_TYPES.each do |x| + assert_subject x, "on_#{x}" + assert_subject x.upcase, "on_#{x}" + end + assert_subject '1TeSt', 'on_1test' + end + end + + describe '.command_from_text' do + subject { ->(*args) { described_class.command_from_text(*args) } } + + def assert_subject(input, cmd, *args) + expected = cmd ? [cmd, args] : cmd + expect(subject.call(*input)).to eq expected + end + + let(:max_cmd_size) { 32 } + let(:long_cmd) { 'a' * (max_cmd_size - 1) } + let(:too_long_cmd) { 'a' * max_cmd_size } + + it 'works for simple commands' do + assert_subject '/test', 'test' + assert_subject '/tE_2_St', 'tE_2_St' + assert_subject '/123', '123' + assert_subject "/#{long_cmd}", long_cmd + end + + it 'works for simple messages' do + assert_subject 'text', nil + assert_subject ' ', nil + assert_subject ' text', nil + assert_subject ' 1', nil + assert_subject ' /text', nil + assert_subject '/te-xt', nil + assert_subject 'text /cmd ', nil + assert_subject "/#{too_long_cmd}", nil + end + + it 'works for mentioned commands' do + assert_subject ['/test@bot', 'bot'], 'test' + assert_subject ['/test@otherbot', 'bot'], nil + assert_subject ['/test@Bot', 'bot'], nil + assert_subject '/test@bot', nil + assert_subject ['/test@bot', true], 'test' + assert_subject ['/test@otherbot', true], 'test' + end + + it 'works for commands with args' do + assert_subject '/test arg', 'test', 'arg' + assert_subject '/test arg 1 2', 'test', 'arg', '1', '2' + assert_subject ['/test@bot arg', 'bot'], 'test', 'arg' + assert_subject ['/test@otherbot arg', 'bot'], nil + assert_subject '/test@bot arg', nil + end + + it 'works for commands with multiline args' do + assert_subject "/test arg\nother", 'test', 'arg', 'other' + assert_subject "/test one\ntwo\n\nthree", 'test', 'one', 'two', 'three' + end + end + + describe '#action_for_payload' do + subject { instance.action_for_payload } + + (described_class::PAYLOAD_TYPES - %w(message)).each do |type| + context "when payload is #{type}" do + let(:payload_type) { type } + it { should eq [false, type, [payload]] } + end + end + + context 'when payload is message' do + let(:payload_type) { 'message' } + let(:payload) { {'text' => text} } + let(:text) { 'test' } + + it { should eq [false, payload_type, [payload]] } + + context 'with command' do + let(:text) { "/test#{"@#{mention}" if mention} arg 1 2" } + let(:mention) {} + it { should eq [true, 'test', %w(arg 1 2)] } + + context 'with mention' do + let(:mention) { bot.username } + it { should eq [true, 'test', %w(arg 1 2)] } + end + + context 'with mention for other bot' do + let(:mention) { other_bot_name } + it { should eq [false, 'message', [payload]] } + end + end + end + end + + describe '#bot_username' do + subject { instance.bot_username } + + context 'when bot is not set' do + let(:bot) {} + it { should eq nil } + end + + context 'when bot is set' do + let(:bot) { double(username: double(:username)) } + it { should eq bot.username } + end + end +end diff --git a/spec/telegram/bot/updates_poller_spec.rb b/spec/telegram/bot/updates_poller_spec.rb new file mode 100644 index 0000000..ca38add --- /dev/null +++ b/spec/telegram/bot/updates_poller_spec.rb @@ -0,0 +1,9 @@ +RSpec.describe Telegram::Bot::UpdatesPoller do + describe '#initialize' do + subject { described_class.new bot, controller } + let(:bot) { double } + let(:controller) { double } + + it { should be } + end +end diff --git a/spec/telegram/bot_spec.rb b/spec/telegram/bot_spec.rb new file mode 100644 index 0000000..1f17405 --- /dev/null +++ b/spec/telegram/bot_spec.rb @@ -0,0 +1,51 @@ +RSpec.describe Telegram::Bot do + it 'has a version number' do + expect(Telegram::Bot::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 diff --git a/telegram-bot.gemspec b/telegram-bot.gemspec new file mode 100644 index 0000000..d0362a1 --- /dev/null +++ b/telegram-bot.gemspec @@ -0,0 +1,25 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'telegram/bot/version' + +Gem::Specification.new do |spec| + spec.name = 'telegram-bot' + spec.version = Telegram::Bot::VERSION + spec.authors = ['Max Melentiev'] + spec.email = ['melentievm@gmail.com'] + + spec.summary = 'Library for building Telegram Bots with Rails integration' + spec.homepage = 'https://github.com/printercu/telegram-bot' + + spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) } + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + spec.add_dependency 'activesupport', '~> 4.0' + spec.add_dependency 'actionpack', '~> 4.0' + spec.add_dependency 'httpclient', '~> 2.7' + spec.add_development_dependency 'bundler', '~> 1.11' + spec.add_development_dependency 'rake', '~> 10.0' +end