зеркало из
https://github.com/glebtv/telegram-bot.git
synced 2026-09-03 17:55:52 +03:00
v0.1.0
Этот коммит содержится в:
22
lib/tasks/telegram-bot.rake
Обычный файл
22
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
|
||||
98
lib/telegram/bot.rb
Обычный файл
98
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
|
||||
"#<Telegram::Bot##{object_id}(#{@username})>"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
require 'telegram/bot/railtie' if defined?(Rails)
|
||||
26
lib/telegram/bot/middleware.rb
Обычный файл
26
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
|
||||
33
lib/telegram/bot/railtie.rb
Обычный файл
33
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
|
||||
58
lib/telegram/bot/routes_helper.rb
Обычный файл
58
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
|
||||
105
lib/telegram/bot/updates_controller.rb
Обычный файл
105
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
|
||||
79
lib/telegram/bot/updates_controller/instrumentation.rb
Обычный файл
79
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
|
||||
38
lib/telegram/bot/updates_controller/log_subscriber.rb
Обычный файл
38
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
|
||||
88
lib/telegram/bot/updates_poller.rb
Обычный файл
88
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
|
||||
9
lib/telegram/bot/version.rb
Обычный файл
9
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
|
||||
41
lib/telegram/bottable.rb
Обычный файл
41
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
|
||||
Ссылка в новой задаче
Block a user