# frozen_string_literal: true require 'abstract_controller' require 'active_support/core_ext/string/inflections' require 'active_support/callbacks' require 'active_support/version' module Telegram module Bot # Base class to create update processors. With callbacks, session and helpers. # # Public methods ending with `!` handle messages with commands. # Message text is automatically parsed into method arguments. # Be sure to use default values and # splat arguments in every action method to not get errors, when user # sends command without necessary args / with extra args. # # def start!(token = nil, *) # if token # # ... # else # # ... # end # end # # def help!(*) # respond_with :message, text: # end # # To process plain text messages (without commands) or other updates just # define public method with name of payload type. # By default they receive payload as an argument, but some of them are called # with more usefuk args: # # def message(message) # respond_with :message, text: "Echo: #{message['text']}" # end # # def inline_query(query, offset) # answer_inline_query results_for_query(query, offset), is_personal: true # end # # To process update run: # # ControllerClass.dispatch(bot, update) # # There is also ability to run action without update: # # ControllerClass.new(bot, from: telegram_user, chat: telegram_chat). # process(:help, *args) # class UpdatesController < AbstractController::Base # rubocop:disable Metrics/ClassLength abstract! %w[ Commands Instrumentation LogSubscriber ReplyHelpers Rescue Session Translation ].each { |name| require "telegram/bot/updates_controller/#{name.underscore}" } %w[ CallbackQueryContext MessageContext TypedUpdate ].each { |mod| autoload mod, "telegram/bot/updates_controller/#{mod.underscore}" } include AbstractController::Callbacks # Redefine callbacks with default terminator. if ActiveSupport::VERSION::MAJOR >= 5 define_callbacks :process_action, skip_after_callbacks_if_terminated: true else define_callbacks :process_action, terminator: ->(_, result) { result == false }, skip_after_callbacks_if_terminated: true end include Commands include Rescue include ReplyHelpers include Translation # Add instrumentations hooks at the bottom, to ensure they instrument # all the methods properly. include Instrumentation extend Session::ConfigMethods PAYLOAD_TYPES = Set.new(%w[ message edited_message channel_post edited_channel_post business_connection business_message edited_business_message deleted_business_messages message_reaction message_reaction_count inline_query chosen_inline_result callback_query shipping_query pre_checkout_query purchased_paid_media poll poll_answer my_chat_member chat_member chat_join_request chat_boost removed_chat_boost ].freeze) class << self # Initialize controller and process update. def dispatch(*args) new(*args).dispatch end def payload_from_update(update) case update when nil then nil when Hash # faster lookup for the case when telegram-bot-types is not used update.find do |type, item| return [item, type] if PAYLOAD_TYPES.include?(type) end else payload_from_typed_update(update) end end private def payload_from_typed_update(update) PAYLOAD_TYPES.find do |type| begin item = update[type] return [item, type] if item rescue Exception # rubocop:disable Lint/RescueException # dry-rb raises exception if field is not defined in schema end end end end attr_internal_reader :bot, :payload, :payload_type, :update, :webhook_request delegate :username, to: :bot, prefix: true, allow_nil: true # `update` can be either update object with hash access & string # keys or Hash with `:from` or `:chat` to override this values and assume # that update is nil. # ActionDispatch::Request object is passed in `webhook_request` when bot running # in webhook mode. def initialize(bot = nil, update = nil, webhook_request = nil) # rubocop:disable Lint/MissingSuper if update.is_a?(Hash) && (update.key?(:from) || update.key?(:chat)) options = update update = nil end @_bot = bot @_update = update @_chat, @_from = options&.values_at(:chat, :from) @_payload, @_payload_type = self.class.payload_from_update(update) @_webhook_request = webhook_request end # Accessor to `'chat'` field of payload. Also tries `'chat'` in `'message'` # when there is no such field in payload. # # Can be overriden with `chat` option for #initialize. def chat # rubocop:disable Metrics/PerceivedComplexity @_chat ||= # rubocop:disable Naming/MemoizedInstanceVariableName if payload if payload.is_a?(Hash) payload['chat'] || (payload['message'] && payload['message']['chat']) else payload.try(:chat) || payload.try(:message)&.chat end end end # Accessor to `'from'` field of payload. Can be overriden with `from` option # for #initialize. def from @_from ||= # rubocop:disable Naming/MemoizedInstanceVariableName payload.is_a?(Hash) ? payload['from'] : payload.try(:from) end # Processes current update. def dispatch action, args = action_for_payload process(action, *args) end attr_internal_reader :action_options # It provides support for passing array as action, where first vaule # is action name and second is action metadata. # This metadata is stored inside action_options def process(action, *args) action, options = action if action.is_a?(Array) @_action_options = options || {} super end # There are multiple ways how action name is calculated for update # (see Commands, MessageContext, etc.). This method represents the # way how action was calculated for current udpate. # # Some of possible values are `:payload, :command, :message_context`. def action_type action_options[:type] || :payload end # Calculates action name and args for payload. # Uses `action_for_#{payload_type}` methods. # If this method doesn't return anything # it uses fallback with action same as payload type. # Returns array `[action, args]`. def action_for_payload if payload_type send("action_for_#{payload_type}") || action_for_default_payload else [:unsupported_payload_type, []] end end def action_for_default_payload [payload_type, [payload]] end def action_for_inline_query [payload_type, [payload['query'], payload['offset']]] end def action_for_chosen_inline_result [payload_type, [payload['result_id'], payload['query']]] end def action_for_callback_query [payload_type, [payload['data']]] end def action_for_poll_answer [payload_type, [payload['poll_id'], payload['option_ids']]] end # Silently ignore unsupported messages to not fail when user crafts # an update with usupported command, callback query context, etc. def action_missing(action, *_args) logger&.debug { "The action '#{action}' is not defined in #{self.class.name}" } nil end PAYLOAD_TYPES.each do |type| method = :"action_for_#{type}" alias_method method, :action_for_default_payload unless instance_methods.include?(method) end ActiveSupport.run_load_hooks('telegram.bot.updates_controller', self) end end end