decidim-spam_detection 0.1.5
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![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,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
|