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.
data/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # Decidim::SpamDetection
2
+ [![codecov](https://codecov.io/gh/OpenSourcePolitics/decidim-spam_detection/branch/master/graph/badge.svg?token=eJu34XLlVu)](https://codecov.io/gh/OpenSourcePolitics/decidim-spam_detection)
3
+ ![Tests](https://github.com/opensourcepolitics/decidim-spam_detection/actions/workflows/tests.yml/badge.svg)
4
+ ![Tests](https://github.com/opensourcepolitics/decidim-spam_detection/actions/workflows/lint.yml/badge.svg)
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,10 @@
1
+ ---
2
+
3
+ base_locale: en
4
+ locales: [en]
5
+
6
+ ignore_unused:
7
+ - "decidim.components.spam_detection.name"
8
+
9
+ ignore_missing:
10
+ - decidim.participatory_processes.scopes.global
@@ -0,0 +1,6 @@
1
+ ---
2
+ en:
3
+ decidim:
4
+ components:
5
+ spam_detection:
6
+ name: SpamDetection
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module SpamDetection
5
+ # This module contains all the domain logic associated to Decidim's SpamDetection
6
+ # component admin panel.
7
+ module Admin
8
+ end
9
+ end
10
+ 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "decidim/core/test/factories"