From 37a63f7b2307efdad2d65b34b24fbd8945e7f7bd Mon Sep 17 00:00:00 2001 From: Max Melentiev Date: Mon, 22 Feb 2016 22:53:59 +0600 Subject: [PATCH] v0.1.0 --- .gitignore | 9 + .rspec | 2 + .rubocop.yml | 27 +++ .travis.yml | 6 + Gemfile | 15 ++ README.md | 184 ++++++++++++++++++ Rakefile | 22 +++ bin/console | 7 + bin/git-hooks/pre-commit | 14 ++ bin/install_git_hooks | 8 + bin/setup | 8 + lib/tasks/telegram-bot.rake | 22 +++ lib/telegram/bot.rb | 98 ++++++++++ lib/telegram/bot/middleware.rb | 26 +++ lib/telegram/bot/railtie.rb | 33 ++++ lib/telegram/bot/routes_helper.rb | 58 ++++++ lib/telegram/bot/updates_controller.rb | 105 ++++++++++ .../bot/updates_controller/instrumentation.rb | 79 ++++++++ .../bot/updates_controller/log_subscriber.rb | 38 ++++ lib/telegram/bot/updates_poller.rb | 88 +++++++++ lib/telegram/bot/version.rb | 9 + lib/telegram/bottable.rb | 41 ++++ spec/spec_helper.rb | 81 ++++++++ spec/telegram/bot/middleware_spec.rb | 18 ++ spec/telegram/bot/routes_helper_spec.rb | 117 +++++++++++ spec/telegram/bot/updates_controller_spec.rb | 133 +++++++++++++ spec/telegram/bot/updates_poller_spec.rb | 9 + spec/telegram/bot_spec.rb | 51 +++++ telegram-bot.gemspec | 25 +++ 29 files changed, 1333 insertions(+) create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 .travis.yml create mode 100644 Gemfile create mode 100644 README.md create mode 100644 Rakefile create mode 100755 bin/console create mode 100755 bin/git-hooks/pre-commit create mode 100755 bin/install_git_hooks create mode 100755 bin/setup create mode 100644 lib/tasks/telegram-bot.rake create mode 100644 lib/telegram/bot.rb create mode 100644 lib/telegram/bot/middleware.rb create mode 100644 lib/telegram/bot/railtie.rb create mode 100644 lib/telegram/bot/routes_helper.rb create mode 100644 lib/telegram/bot/updates_controller.rb create mode 100644 lib/telegram/bot/updates_controller/instrumentation.rb create mode 100644 lib/telegram/bot/updates_controller/log_subscriber.rb create mode 100644 lib/telegram/bot/updates_poller.rb create mode 100644 lib/telegram/bot/version.rb create mode 100644 lib/telegram/bottable.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/telegram/bot/middleware_spec.rb create mode 100644 spec/telegram/bot/routes_helper_spec.rb create mode 100644 spec/telegram/bot/updates_controller_spec.rb create mode 100644 spec/telegram/bot/updates_poller_spec.rb create mode 100644 spec/telegram/bot_spec.rb create mode 100644 telegram-bot.gemspec 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