decidim-spam_detection 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE-AGPLv3.txt +661 -0
- data/README.md +75 -0
- data/Rakefile +15 -0
- data/app/commands/decidim/admin/unblock_user.rb +55 -0
- data/app/commands/decidim/admin/unreport_user.rb +53 -0
- data/app/jobs/decidim/spam_detection/mark_users_job.rb +19 -0
- data/app/services/decidim/spam_detection/mark_users_service.rb +63 -0
- data/config/assets.rb +9 -0
- data/config/i18n-tasks.yml +10 -0
- data/config/locales/en.yml +6 -0
- data/lib/decidim/spam_detection/abstract_spam_user_command.rb +65 -0
- data/lib/decidim/spam_detection/admin.rb +10 -0
- data/lib/decidim/spam_detection/admin_engine.rb +27 -0
- data/lib/decidim/spam_detection/api_proxy.rb +53 -0
- data/lib/decidim/spam_detection/block_spam_user_command.rb +37 -0
- data/lib/decidim/spam_detection/command.rb +50 -0
- data/lib/decidim/spam_detection/command_errors.rb +37 -0
- data/lib/decidim/spam_detection/engine.rb +13 -0
- data/lib/decidim/spam_detection/report_spam_user_command.rb +38 -0
- data/lib/decidim/spam_detection/spam_user_command_adapter.rb +33 -0
- data/lib/decidim/spam_detection/test/factories.rb +3 -0
- data/lib/decidim/spam_detection/version.rb +16 -0
- data/lib/decidim/spam_detection.rb +19 -0
- data/lib/tasks/decidim_spam_detection.rake +10 -0
- metadata +81 -0
    
        data/README.md
    ADDED
    
    | @@ -0,0 +1,75 @@ | |
| 1 | 
            +
            # Decidim::SpamDetection
         | 
| 2 | 
            +
            [](https://codecov.io/gh/OpenSourcePolitics/decidim-spam_detection)
         | 
| 3 | 
            +
            
         | 
| 4 | 
            +
            
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            ## Usage
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            SpamDetection is a detection bot made by OpenSourcePolitics. It works with a [spam detection service](https://github.com/OpenSourcePolitics/spam_detection) 
         | 
| 9 | 
            +
            which marks the user with a spam probability score, between 0.7 
         | 
| 10 | 
            +
            and 0.99 it is probable, and above 0.99 it is very sure.
         | 
| 11 | 
            +
             | 
| 12 | 
            +
            By default, the bot does not blocks the user, it only reports them.
         | 
| 13 | 
            +
            All reports and blocks are made like regular Decidim ones.
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            ### Installation
         | 
| 16 | 
            +
             | 
| 17 | 
            +
            Add this line to your application's Gemfile:
         | 
| 18 | 
            +
             | 
| 19 | 
            +
            ```ruby
         | 
| 20 | 
            +
            gem "decidim-spam_detection", git: "https://github.com/OpenSourcePolitics/decidim-spam_detection.git"
         | 
| 21 | 
            +
            ```
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            And then execute:
         | 
| 24 | 
            +
             | 
| 25 | 
            +
            ```bash
         | 
| 26 | 
            +
            bundle exec rake decidim:spam_detection:mark_users
         | 
| 27 | 
            +
            ```
         | 
| 28 | 
            +
             | 
| 29 | 
            +
            if you are using sidekiq scheduler you can use the following configuration:
         | 
| 30 | 
            +
            ```
         | 
| 31 | 
            +
            :queues:
         | 
| 32 | 
            +
            - user_report
         | 
| 33 | 
            +
            - block_user
         | 
| 34 | 
            +
            - scheduled
         | 
| 35 | 
            +
             | 
| 36 | 
            +
            :schedule:
         | 
| 37 | 
            +
                DetectSpamUsers:
         | 
| 38 | 
            +
                cron: '0 0 8 * * *' # Run at 08:00
         | 
| 39 | 
            +
                class: Decidim::SpamDetection::MarkUsersJob
         | 
| 40 | 
            +
                queue: scheduled
         | 
| 41 | 
            +
            ```
         | 
| 42 | 
            +
             | 
| 43 | 
            +
            ### Further configuration
         | 
| 44 | 
            +
            list of env var, default value and their usage:
         | 
| 45 | 
            +
            ```
         | 
| 46 | 
            +
            SPAM_DETECTION_API_AUTH_TOKEN
         | 
| 47 | 
            +
                default_value: dummy
         | 
| 48 | 
            +
                usage: Token auth for authentication used by external service, ask us for more details
         | 
| 49 | 
            +
            SPAM_DETECTION_API_URL 
         | 
| 50 | 
            +
                default_value: "http://localhost:8080/api"
         | 
| 51 | 
            +
                usage: URL of the external service
         | 
| 52 | 
            +
            SPAM_DETECTION_NAME 
         | 
| 53 | 
            +
                default_value: "spam detection bot"
         | 
| 54 | 
            +
                usage: Name used by the spam detection bot
         | 
| 55 | 
            +
            SPAM_DETECTION_NICKNAME 
         | 
| 56 | 
            +
                default_value: "Spam_detection_bot"
         | 
| 57 | 
            +
                usage: Nickname used by the spam detection bot
         | 
| 58 | 
            +
            SPAM_DETECTION_EMAIL 
         | 
| 59 | 
            +
                default_value: "spam_detection_bot@opensourcepolitcs.eu"
         | 
| 60 | 
            +
                usage: Email used by the spam detection bot
         | 
| 61 | 
            +
            PERFORM_BLOCK_USER 
         | 
| 62 | 
            +
                default_value: false
         | 
| 63 | 
            +
                usage: Determine if the bot can perform blocking, default mode is just report
         | 
| 64 | 
            +
            ```
         | 
| 65 | 
            +
             | 
| 66 | 
            +
            ## API usage
         | 
| 67 | 
            +
            We can provide the detection service, please check us out at [contact@opensourcepolitics.eu](mailto:contact@opensourcepolitics.eu)
         | 
| 68 | 
            +
             | 
| 69 | 
            +
            ## Contributing
         | 
| 70 | 
            +
             | 
| 71 | 
            +
            See [Decidim](https://github.com/decidim/decidim).
         | 
| 72 | 
            +
             | 
| 73 | 
            +
            ## License
         | 
| 74 | 
            +
             | 
| 75 | 
            +
            This engine is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE.
         | 
    
        data/Rakefile
    ADDED
    
    | @@ -0,0 +1,15 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "decidim/dev/common_rake"
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            desc "Generates a dummy app for testing"
         | 
| 6 | 
            +
            task test_app: "decidim:generate_external_test_app"
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            desc "Generates a development app."
         | 
| 9 | 
            +
            task development_app: "decidim:generate_external_development_app"
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            task :push_tag_and_release do
         | 
| 12 | 
            +
              system("git tag v#{Decidim::SpamDetection.version}")
         | 
| 13 | 
            +
              system("git push --tags")
         | 
| 14 | 
            +
              system("gh release create v#{Decidim::SpamDetection.version}")
         | 
| 15 | 
            +
            end
         | 
| @@ -0,0 +1,55 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Decidim
         | 
| 4 | 
            +
              module Admin
         | 
| 5 | 
            +
                class UnblockUser < Rectify::Command
         | 
| 6 | 
            +
                  # Public: Initializes the command.
         | 
| 7 | 
            +
                  #
         | 
| 8 | 
            +
                  # blocked_user - the user that is unblocked
         | 
| 9 | 
            +
                  # current_user - the user performing the action
         | 
| 10 | 
            +
                  def initialize(blocked_user, current_user)
         | 
| 11 | 
            +
                    @blocked_user = blocked_user
         | 
| 12 | 
            +
                    @current_user = current_user
         | 
| 13 | 
            +
                  end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                  # Executes the command. Broadcasts these events:
         | 
| 16 | 
            +
                  #
         | 
| 17 | 
            +
                  # - :ok when everything is valid, together with the resource.
         | 
| 18 | 
            +
                  # - :invalid if the resource is not reported
         | 
| 19 | 
            +
                  #
         | 
| 20 | 
            +
                  # Returns nothing.
         | 
| 21 | 
            +
                  def call
         | 
| 22 | 
            +
                    return broadcast(:invalid) unless @blocked_user.blocked?
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                    unblock!
         | 
| 25 | 
            +
                    add_spam_detection_metadata!
         | 
| 26 | 
            +
                    broadcast(:ok, @blocked_user)
         | 
| 27 | 
            +
                  end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                  private
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                  def unblock!
         | 
| 32 | 
            +
                    Decidim.traceability.perform_action!(
         | 
| 33 | 
            +
                      "unblock",
         | 
| 34 | 
            +
                      @blocked_user,
         | 
| 35 | 
            +
                      @current_user,
         | 
| 36 | 
            +
                      extra: {
         | 
| 37 | 
            +
                        reportable_type: @blocked_user.class.name
         | 
| 38 | 
            +
                      }
         | 
| 39 | 
            +
                    ) do
         | 
| 40 | 
            +
                      @blocked_user.blocked = false
         | 
| 41 | 
            +
                      @blocked_user.blocked_at = nil
         | 
| 42 | 
            +
                      @blocked_user.block_id = nil
         | 
| 43 | 
            +
                      @blocked_user.name = @blocked_user.user_name
         | 
| 44 | 
            +
                      @blocked_user.save!
         | 
| 45 | 
            +
                    end
         | 
| 46 | 
            +
                  end
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                  def add_spam_detection_metadata!
         | 
| 49 | 
            +
                    return if @blocked_user.extended_data.dig("spam_detection", "blocked_at").blank?
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                    @blocked_user.update!(extended_data: @blocked_user.extended_data.dup.deep_merge("spam_detection" => { "unblocked_at": Time.current }))
         | 
| 52 | 
            +
                  end
         | 
| 53 | 
            +
                end
         | 
| 54 | 
            +
              end
         | 
| 55 | 
            +
            end
         | 
| @@ -0,0 +1,53 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Decidim
         | 
| 4 | 
            +
              module Admin
         | 
| 5 | 
            +
                class UnreportUser < Rectify::Command
         | 
| 6 | 
            +
                  # Public: Initializes the command.
         | 
| 7 | 
            +
                  #
         | 
| 8 | 
            +
                  # reportable - A Decidim::User - The user reported
         | 
| 9 | 
            +
                  # current_user - the user performing the action
         | 
| 10 | 
            +
                  def initialize(reportable, current_user)
         | 
| 11 | 
            +
                    @reportable = reportable
         | 
| 12 | 
            +
                    @current_user = current_user
         | 
| 13 | 
            +
                  end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                  # Executes the command. Broadcasts these events:
         | 
| 16 | 
            +
                  #
         | 
| 17 | 
            +
                  # - :ok when everything is valid, together with the resource.
         | 
| 18 | 
            +
                  # - :invalid if the resource is not reported
         | 
| 19 | 
            +
                  #
         | 
| 20 | 
            +
                  # Returns nothing.
         | 
| 21 | 
            +
                  def call
         | 
| 22 | 
            +
                    return broadcast(:invalid) unless @reportable.reported?
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                    unreport!
         | 
| 25 | 
            +
                    add_spam_detection_metadata!
         | 
| 26 | 
            +
                    broadcast(:ok, @reportable)
         | 
| 27 | 
            +
                  end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                  private
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                  def unreport!
         | 
| 32 | 
            +
                    Decidim.traceability.perform_action!(
         | 
| 33 | 
            +
                      "unreport",
         | 
| 34 | 
            +
                      @reportable.user_moderation,
         | 
| 35 | 
            +
                      @current_user,
         | 
| 36 | 
            +
                      extra: {
         | 
| 37 | 
            +
                        reportable_type: @reportable.class.name,
         | 
| 38 | 
            +
                        username: @reportable.name,
         | 
| 39 | 
            +
                        user_id: @reportable.id
         | 
| 40 | 
            +
                      }
         | 
| 41 | 
            +
                    ) do
         | 
| 42 | 
            +
                      @reportable.user_moderation.destroy!
         | 
| 43 | 
            +
                    end
         | 
| 44 | 
            +
                  end
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                  def add_spam_detection_metadata!
         | 
| 47 | 
            +
                    return if @reportable.extended_data.dig("spam_detection", "reported_at").blank?
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                    @reportable.update!(extended_data: @reportable.extended_data.dup.deep_merge("spam_detection" => { "unreported_at": Time.current }))
         | 
| 50 | 
            +
                  end
         | 
| 51 | 
            +
                end
         | 
| 52 | 
            +
              end
         | 
| 53 | 
            +
            end
         | 
| @@ -0,0 +1,19 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Decidim
         | 
| 4 | 
            +
              module SpamDetection
         | 
| 5 | 
            +
                class MarkUsersJob < ApplicationJob
         | 
| 6 | 
            +
                  queue_as :default
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  def perform
         | 
| 9 | 
            +
                    mark_users_service.call
         | 
| 10 | 
            +
                  end
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  private
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  def mark_users_service
         | 
| 15 | 
            +
                    @mark_users_service ||= Decidim::SpamDetection::MarkUsersService
         | 
| 16 | 
            +
                  end
         | 
| 17 | 
            +
                end
         | 
| 18 | 
            +
              end
         | 
| 19 | 
            +
            end
         | 
| @@ -0,0 +1,63 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "uri"
         | 
| 4 | 
            +
            require "net/http"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module Decidim
         | 
| 7 | 
            +
              module SpamDetection
         | 
| 8 | 
            +
                class MarkUsersService
         | 
| 9 | 
            +
                  PUBLICY_SEARCHABLE_COLUMNS = [
         | 
| 10 | 
            +
                    :id,
         | 
| 11 | 
            +
                    :decidim_organization_id,
         | 
| 12 | 
            +
                    :sign_in_count,
         | 
| 13 | 
            +
                    :personal_url,
         | 
| 14 | 
            +
                    :about,
         | 
| 15 | 
            +
                    :avatar,
         | 
| 16 | 
            +
                    :extended_data,
         | 
| 17 | 
            +
                    :followers_count,
         | 
| 18 | 
            +
                    :following_count,
         | 
| 19 | 
            +
                    :invitations_count,
         | 
| 20 | 
            +
                    :failed_attempts,
         | 
| 21 | 
            +
                    :admin
         | 
| 22 | 
            +
                  ].freeze
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                  def initialize
         | 
| 25 | 
            +
                    @users = Decidim::User.left_outer_joins(:user_moderation)
         | 
| 26 | 
            +
                                          .where(decidim_user_moderations: { decidim_user_id: nil })
         | 
| 27 | 
            +
                                          .where(admin: false, blocked: false, deleted_at: nil)
         | 
| 28 | 
            +
                                          .where("(extended_data #> '{spam_detection, unreported_at}') is null")
         | 
| 29 | 
            +
                                          .where("(extended_data #> '{spam_detection, unblocked_at}') is null")
         | 
| 30 | 
            +
                    @results = []
         | 
| 31 | 
            +
                  end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                  def self.call
         | 
| 34 | 
            +
                    new.ask_and_mark
         | 
| 35 | 
            +
                  end
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                  def ask_and_mark
         | 
| 38 | 
            +
                    spam_probability_array = Decidim::SpamDetection::ApiProxy.request(cleaned_users)
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                    mark_spam_users(merge_response_with_users(spam_probability_array))
         | 
| 41 | 
            +
                  end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                  def mark_spam_users(probability_array)
         | 
| 44 | 
            +
                    probability_array.each do |probability_hash|
         | 
| 45 | 
            +
                      @results << Decidim::SpamDetection::SpamUserCommandAdapter.call(probability_hash).result
         | 
| 46 | 
            +
                    end
         | 
| 47 | 
            +
                  end
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                  def cleaned_users
         | 
| 50 | 
            +
                    @cleaned_users ||= @users.select(PUBLICY_SEARCHABLE_COLUMNS)
         | 
| 51 | 
            +
                                             .map { |u| u.serializable_hash(force_except: true) }
         | 
| 52 | 
            +
                  end
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                  def merge_response_with_users(response)
         | 
| 55 | 
            +
                    response.map { |resp| resp.merge("original_user" => @users.find(resp["id"])) }
         | 
| 56 | 
            +
                  end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                  def status
         | 
| 59 | 
            +
                    @results.tally
         | 
| 60 | 
            +
                  end
         | 
| 61 | 
            +
                end
         | 
| 62 | 
            +
              end
         | 
| 63 | 
            +
            end
         | 
    
        data/config/assets.rb
    ADDED
    
    | @@ -0,0 +1,9 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            base_path = File.expand_path("..", __dir__)
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            Decidim::Webpacker.register_path("#{base_path}/app/packs")
         | 
| 6 | 
            +
            Decidim::Webpacker.register_entrypoints(
         | 
| 7 | 
            +
              decidim_spam_detection: "#{base_path}/app/packs/entrypoints/decidim_spam_detection.js"
         | 
| 8 | 
            +
            )
         | 
| 9 | 
            +
            Decidim::Webpacker.register_stylesheet_import("stylesheets/decidim/spam_detection/spam_detection")
         | 
| @@ -0,0 +1,65 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "uri"
         | 
| 4 | 
            +
            require "net/http"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module Decidim
         | 
| 7 | 
            +
              module SpamDetection
         | 
| 8 | 
            +
                class AbstractSpamUserCommand
         | 
| 9 | 
            +
                  SPAM_USER = {
         | 
| 10 | 
            +
                    name: ENV.fetch("SPAM_DETECTION_NAME", "spam detection bot"),
         | 
| 11 | 
            +
                    nickname: ENV.fetch("SPAM_DETECTION_NICKNAME", "Spam_detection_bot"),
         | 
| 12 | 
            +
                    email: ENV.fetch("SPAM_DETECTION_EMAIL", "spam_detection_bot@opensourcepolitcs.eu")
         | 
| 13 | 
            +
                  }.freeze
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                  include Decidim::FormFactory
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                  def initialize(user, probability)
         | 
| 18 | 
            +
                    @user = user
         | 
| 19 | 
            +
                    @probability = probability
         | 
| 20 | 
            +
                    @moderator = moderation_user
         | 
| 21 | 
            +
                  end
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                  def call
         | 
| 24 | 
            +
                    raise NotImplementedError
         | 
| 25 | 
            +
                  end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                  def moderation_user
         | 
| 28 | 
            +
                    moderation_admin_params = {
         | 
| 29 | 
            +
                      name: SPAM_USER[:name],
         | 
| 30 | 
            +
                      nickname: SPAM_USER[:nickname],
         | 
| 31 | 
            +
                      email: SPAM_USER[:email],
         | 
| 32 | 
            +
                      admin: true,
         | 
| 33 | 
            +
                      organization: @user.organization
         | 
| 34 | 
            +
                    }
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                    moderation_admin = Decidim::User.find_by(moderation_admin_params)
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                    return moderation_admin unless moderation_admin.nil?
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                    create_moderation_admin(moderation_admin_params)
         | 
| 41 | 
            +
                  end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                  def create_moderation_admin(params)
         | 
| 44 | 
            +
                    password = ::Devise.friendly_token(::Devise.password_length.last)
         | 
| 45 | 
            +
                    additional_params = {
         | 
| 46 | 
            +
                      password: password,
         | 
| 47 | 
            +
                      password_confirmation: password,
         | 
| 48 | 
            +
                      tos_agreement: true,
         | 
| 49 | 
            +
                      email_on_notification: false,
         | 
| 50 | 
            +
                      email_on_moderations: false
         | 
| 51 | 
            +
                    }
         | 
| 52 | 
            +
                    moderation_admin = Decidim::User.new(params.merge(additional_params))
         | 
| 53 | 
            +
                    moderation_admin.skip_confirmation!
         | 
| 54 | 
            +
                    moderation_admin.save
         | 
| 55 | 
            +
                    moderation_admin
         | 
| 56 | 
            +
                  end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                  def add_spam_detection_metadata!(metadata)
         | 
| 59 | 
            +
                    @user.update!(extended_data: @user.extended_data
         | 
| 60 | 
            +
                                                    .dup
         | 
| 61 | 
            +
                                                    .deep_merge("spam_detection" => metadata))
         | 
| 62 | 
            +
                  end
         | 
| 63 | 
            +
                end
         | 
| 64 | 
            +
              end
         | 
| 65 | 
            +
            end
         | 
| @@ -0,0 +1,27 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Decidim
         | 
| 4 | 
            +
              module SpamDetection
         | 
| 5 | 
            +
                # This is the engine that runs on the public interface of `SpamDetection`.
         | 
| 6 | 
            +
                class AdminEngine < ::Rails::Engine
         | 
| 7 | 
            +
                  isolate_namespace Decidim::SpamDetection::Admin
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                  paths["db/migrate"] = nil
         | 
| 10 | 
            +
                  paths["lib/tasks"] = nil
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  routes do
         | 
| 13 | 
            +
                    # Add admin engine routes here
         | 
| 14 | 
            +
                    # resources :spam_detection do
         | 
| 15 | 
            +
                    #   collection do
         | 
| 16 | 
            +
                    #     resources :exports, only: [:create]
         | 
| 17 | 
            +
                    #   end
         | 
| 18 | 
            +
                    # end
         | 
| 19 | 
            +
                    # root to: "spam_detection#index"
         | 
| 20 | 
            +
                  end
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                  def load_seed
         | 
| 23 | 
            +
                    nil
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
                end
         | 
| 26 | 
            +
              end
         | 
| 27 | 
            +
            end
         | 
| @@ -0,0 +1,53 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "uri"
         | 
| 4 | 
            +
            require "net/http"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module Decidim
         | 
| 7 | 
            +
              module SpamDetection
         | 
| 8 | 
            +
                class ApiProxy
         | 
| 9 | 
            +
                  URL = URI(ENV.fetch("SPAM_DETECTION_API_URL", "http://localhost:8080/api"))
         | 
| 10 | 
            +
                  AUTH_TOKEN = ENV.fetch("SPAM_DETECTION_API_AUTH_TOKEN", "dummy")
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  def initialize(data_array, batch_size)
         | 
| 13 | 
            +
                    @data_array = data_array
         | 
| 14 | 
            +
                    @batch_size = batch_size
         | 
| 15 | 
            +
                    @retries = [3, 5, 10]
         | 
| 16 | 
            +
                  end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                  def self.request(data_array, batch_size = 1000)
         | 
| 19 | 
            +
                    new(data_array, batch_size).send_request_in_batch
         | 
| 20 | 
            +
                  end
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                  def send_request_in_batch
         | 
| 23 | 
            +
                    responses = []
         | 
| 24 | 
            +
                    @data_array.each_slice(@batch_size) do |subdata_array|
         | 
| 25 | 
            +
                      responses << JSON.parse(send_request_to_api(subdata_array))
         | 
| 26 | 
            +
                    end
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                    responses.flatten
         | 
| 29 | 
            +
                  end
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                  def send_request_to_api(data)
         | 
| 32 | 
            +
                    http = Net::HTTP.new(URL.host, URL.port)
         | 
| 33 | 
            +
                    request = Net::HTTP::Post.new(URL)
         | 
| 34 | 
            +
                    request["Content-Type"] = "application/json"
         | 
| 35 | 
            +
                    request["AUTH_TOKEN"] = AUTH_TOKEN
         | 
| 36 | 
            +
                    request.body = JSON.dump(data)
         | 
| 37 | 
            +
                    http.use_ssl = true if self.class.use_ssl?(URL)
         | 
| 38 | 
            +
                    response = http.request(request)
         | 
| 39 | 
            +
                    response.read_body
         | 
| 40 | 
            +
                  rescue Net::ReadTimeout
         | 
| 41 | 
            +
                    raise Net::ReadTimeout if @retries.empty?
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                    sleep @retries.first
         | 
| 44 | 
            +
                    @retries.shift
         | 
| 45 | 
            +
                    retry
         | 
| 46 | 
            +
                  end
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                  def self.use_ssl?(url)
         | 
| 49 | 
            +
                    url.scheme == "https"
         | 
| 50 | 
            +
                  end
         | 
| 51 | 
            +
                end
         | 
| 52 | 
            +
              end
         | 
| 53 | 
            +
            end
         | 
| @@ -0,0 +1,37 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "uri"
         | 
| 4 | 
            +
            require "net/http"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module Decidim
         | 
| 7 | 
            +
              module SpamDetection
         | 
| 8 | 
            +
                class BlockSpamUserCommand < Decidim::SpamDetection::AbstractSpamUserCommand
         | 
| 9 | 
            +
                  prepend Decidim::SpamDetection::Command
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  def call
         | 
| 12 | 
            +
                    form = form(Decidim::Admin::BlockUserForm).from_params(
         | 
| 13 | 
            +
                      justification: "The user was blocked because of a high spam probability by Decidim spam detection bot"
         | 
| 14 | 
            +
                    )
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                    moderator = @moderator
         | 
| 17 | 
            +
                    user = @user
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                    form.define_singleton_method(:user) { user }
         | 
| 20 | 
            +
                    form.define_singleton_method(:current_user) { moderator }
         | 
| 21 | 
            +
                    form.define_singleton_method(:blocking_user) { moderator }
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                    Decidim::Admin::BlockUser.call(form)
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                    add_spam_detection_metadata!({
         | 
| 26 | 
            +
                                                   "blocked_at" => Time.current,
         | 
| 27 | 
            +
                                                   "spam_probability" => @probability
         | 
| 28 | 
            +
                                                 })
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                    @user.create_user_moderation
         | 
| 31 | 
            +
                    Rails.logger.info("User with id #{@user["id"]} was blocked for spam")
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                    :ok
         | 
| 34 | 
            +
                  end
         | 
| 35 | 
            +
                end
         | 
| 36 | 
            +
              end
         | 
| 37 | 
            +
            end
         | 
| @@ -0,0 +1,50 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "decidim/spam_detection/command_errors"
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            module Decidim
         | 
| 6 | 
            +
              module SpamDetection
         | 
| 7 | 
            +
                module Command
         | 
| 8 | 
            +
                  attr_reader :result
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  module ClassMethods
         | 
| 11 | 
            +
                    def call(*args, **kwargs)
         | 
| 12 | 
            +
                      new(*args, **kwargs).call
         | 
| 13 | 
            +
                    end
         | 
| 14 | 
            +
                  end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  def self.prepended(base)
         | 
| 17 | 
            +
                    base.extend ClassMethods
         | 
| 18 | 
            +
                  end
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                  def call
         | 
| 21 | 
            +
                    raise NotImplementedError unless defined?(super)
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                    @called = true
         | 
| 24 | 
            +
                    @result = super
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                    self
         | 
| 27 | 
            +
                  end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                  def success?
         | 
| 30 | 
            +
                    called? && !failure?
         | 
| 31 | 
            +
                  end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                  def failure?
         | 
| 34 | 
            +
                    called? && errors.any?
         | 
| 35 | 
            +
                  end
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                  def errors
         | 
| 38 | 
            +
                    return super if defined?(super)
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                    @errors ||= Decidim::SpamDetection::CommandErrors.new
         | 
| 41 | 
            +
                  end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                  private
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                  def called?
         | 
| 46 | 
            +
                    @called ||= false
         | 
| 47 | 
            +
                  end
         | 
| 48 | 
            +
                end
         | 
| 49 | 
            +
              end
         | 
| 50 | 
            +
            end
         | 
| @@ -0,0 +1,37 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Decidim
         | 
| 4 | 
            +
              module SpamDetection
         | 
| 5 | 
            +
                class CommandErrors < Hash
         | 
| 6 | 
            +
                  def add(key, value, _opts = {})
         | 
| 7 | 
            +
                    self[key] ||= []
         | 
| 8 | 
            +
                    self[key] << value
         | 
| 9 | 
            +
                    self[key].uniq!
         | 
| 10 | 
            +
                  end
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  def add_multiple_errors(errors_hash)
         | 
| 13 | 
            +
                    errors_hash.each do |key, values|
         | 
| 14 | 
            +
                      values.each { |value| add key, value }
         | 
| 15 | 
            +
                    end
         | 
| 16 | 
            +
                  end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                  def each
         | 
| 19 | 
            +
                    each_key do |field|
         | 
| 20 | 
            +
                      self[field].each { |message| yield field, message }
         | 
| 21 | 
            +
                    end
         | 
| 22 | 
            +
                  end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                  def full_messages
         | 
| 25 | 
            +
                    map { |attribute, message| full_message(attribute, message) }
         | 
| 26 | 
            +
                  end
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                  private
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                  def full_message(attribute, message)
         | 
| 31 | 
            +
                    return message if attribute == :base
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                    "#{attribute.to_s.tr(".", "_").capitalize} #{message}"
         | 
| 34 | 
            +
                  end
         | 
| 35 | 
            +
                end
         | 
| 36 | 
            +
              end
         | 
| 37 | 
            +
            end
         | 
| @@ -0,0 +1,13 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "rails"
         | 
| 4 | 
            +
            require "decidim/core"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module Decidim
         | 
| 7 | 
            +
              module SpamDetection
         | 
| 8 | 
            +
                # This is the engine that runs on the public interface of spam_detection.
         | 
| 9 | 
            +
                class Engine < ::Rails::Engine
         | 
| 10 | 
            +
                  isolate_namespace Decidim::SpamDetection
         | 
| 11 | 
            +
                end
         | 
| 12 | 
            +
              end
         | 
| 13 | 
            +
            end
         | 
| @@ -0,0 +1,38 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "uri"
         | 
| 4 | 
            +
            require "net/http"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module Decidim
         | 
| 7 | 
            +
              module SpamDetection
         | 
| 8 | 
            +
                class ReportSpamUserCommand < Decidim::SpamDetection::AbstractSpamUserCommand
         | 
| 9 | 
            +
                  prepend Decidim::SpamDetection::Command
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  def call
         | 
| 12 | 
            +
                    form = form(Decidim::ReportForm).from_params(
         | 
| 13 | 
            +
                      reason: "spam",
         | 
| 14 | 
            +
                      details: "The user was marked as spam by Decidim spam detection bot"
         | 
| 15 | 
            +
                    )
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                    current_organization = @user.organization
         | 
| 18 | 
            +
                    moderator = @moderator
         | 
| 19 | 
            +
                    user = @user
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                    report = Decidim::CreateUserReport.new(form, user, moderator)
         | 
| 22 | 
            +
                    report.define_singleton_method(:current_organization) { current_organization }
         | 
| 23 | 
            +
                    report.define_singleton_method(:current_user) { moderator }
         | 
| 24 | 
            +
                    report.define_singleton_method(:reportable) { user }
         | 
| 25 | 
            +
                    report.call
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                    add_spam_detection_metadata!({
         | 
| 28 | 
            +
                                                   "reported_at" => Time.current,
         | 
| 29 | 
            +
                                                   "spam_probability" => @probability
         | 
| 30 | 
            +
                                                 })
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                    Rails.logger.info("User with id #{user.id} was reported for spam")
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                    :ok
         | 
| 35 | 
            +
                  end
         | 
| 36 | 
            +
                end
         | 
| 37 | 
            +
              end
         | 
| 38 | 
            +
            end
         | 
| @@ -0,0 +1,33 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Decidim
         | 
| 4 | 
            +
              module SpamDetection
         | 
| 5 | 
            +
                class SpamUserCommandAdapter
         | 
| 6 | 
            +
                  prepend Decidim::SpamDetection::Command
         | 
| 7 | 
            +
                  SPAM_LEVEL = { very_sure: 0.99, probable: 0.7 }.freeze
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                  def self.perform_block_user?
         | 
| 10 | 
            +
                    ENV.fetch("PERFORM_BLOCK_USER", false)
         | 
| 11 | 
            +
                  end
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                  def initialize(probability_hash)
         | 
| 14 | 
            +
                    @probability = probability_hash["spam_probability"]
         | 
| 15 | 
            +
                    @user = probability_hash["original_user"]
         | 
| 16 | 
            +
                  end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                  def call
         | 
| 19 | 
            +
                    if @probability > SPAM_LEVEL[:very_sure] && self.class.perform_block_user?
         | 
| 20 | 
            +
                      Decidim::SpamDetection::BlockSpamUserCommand.call(@user, @probability)
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                      :blocked_user
         | 
| 23 | 
            +
                    elsif @probability > SPAM_LEVEL[:probable]
         | 
| 24 | 
            +
                      Decidim::SpamDetection::ReportSpamUserCommand.call(@user, @probability)
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                      :reported_user
         | 
| 27 | 
            +
                    else
         | 
| 28 | 
            +
                      :nothing
         | 
| 29 | 
            +
                    end
         | 
| 30 | 
            +
                  end
         | 
| 31 | 
            +
                end
         | 
| 32 | 
            +
              end
         | 
| 33 | 
            +
            end
         |