printercu/telegram-bot

View on GitHub
lib/telegram/bot/updates_controller.rb

Summary

Maintainability
A
0 mins
Test Coverage
# 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
        poll
        poll_answer
        my_chat_member
        chat_member
        chat_join_request
        chat_boost
        removed_chat_boost
        pre_checkout_query
      ].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